@jackmorgan/phaser-player
v0.4.0
Published
Embeddable HLS music player for the Phaser streaming network — USDC micropayments on Base, headless and web-component entry points.
Downloads
547
Maintainers
Readme
@jackmorgan/phaser-player
Embeddable HLS music player for the Phaser streaming network. Handles wallet-gated paid streams, USDC micropayments on Base, and HLS playback in one self-contained bundle.
Two entry points: a headless Player class for custom UI, and a <phaser-player> web component that comes with a default themeable UI.
- Self-contained:
hls.jsandviemare bundled — no peer dependencies for consumers to wire up. - Works in every modern browser (HLS via
hls.js, with native Safari fallback). - Ships both ESM and IIFE builds plus TypeScript definitions.
- Bring your own auth: optional
AuthAdapterhook, plus ready-madecreateEip1193Adapter,runSiwe,waitForCoinbaseProvider, andPhaserClienthelpers so you don't re-implement the glue. - Typed errors:
PhaserErrorwith stablecodevalues so apps can branch on payment failures without regex-matching message strings.
Install
npm install @jackmorgan/phaser-playerOr drop in the IIFE build from a CDN:
<script src="https://unpkg.com/@jackmorgan/phaser-player"></script>
<script>
const player = new Phaser.Player();
</script>Quick start — web component
<script type="module">
import "@jackmorgan/phaser-player";
</script>
<phaser-player
track-id="trk_abc123"
theme="dark"
></phaser-player>Quick start — headless
import { Player } from "@jackmorgan/phaser-player";
const player = new Player();
player.on("track:loaded", (e) => console.log("loaded", e.title));
player.on("stream:progress", (e) => console.log(e.currentTime));
await player.load("trk_abc123");
// For paid tracks, connect a wallet first:
await player.connectWallet();
await player.play();Player API
Constructor
new Player(opts: PlayerOptions)| Option | Type | Description |
|-------------------|-----------------|-------------------------------------------------------------------------------|
| apiUrl | string | Override the Phaser API base URL. Default: https://phaser-api.jackmorgan.xyz. |
| rpcUrl | string | Override the default Base RPC endpoint used by the built-in wallet module. |
| spendAllowance | number | USDC allowance (whole-unit) the default spend permission prompts for. |
| spendPeriodDays | number | Rolling window (days) for the spend permission. |
| auth | AuthAdapter | Supply an external wallet/auth adapter (see below) to replace the internal one.|
The catalog and streaming endpoints are open — no API key required. Paid streams still require a connected wallet to sign USDC spend permissions.
Methods
| Method | Returns | Notes |
|-------------------------------------------|--------------------------|--------------------------------------------------------------------------------------|
| load(trackId) | Promise<TrackInfo> | Fetch track metadata and prep for playback. |
| play() | Promise<void> | Pays for the stream (if priced) and starts HLS playback. |
| pause() | void | Pause the underlying <audio> element. |
| resume() | void | Resume after pause(). |
| seek(seconds) | void | Seek to an absolute time. |
| setVolume(level) | void | level in [0, 1]. |
| stop() | void | Stop playback and reset to idle. |
| connectWallet() | Promise<string> | Resolves to the connected address. |
| disconnectWallet() | void | Forget the current wallet session. |
| getBalance() | Promise<string> | Returns USDC balance on Base as a formatted string. |
| getEthBalance() | Promise<string> | Returns native ETH balance on Base as a formatted string (5 decimals). |
| setAuthToken(jwt) | void | Push a JWT from an external auth adapter. null clears. Preserves audio/queue. |
| setAuthAdapter(adapter \| null) | void | Swap the external auth adapter in place (sign-in / sign-out) without rebuilding the Player. Requires construction with opts.auth. |
| buildSpendPermission({ amountUsdc? }) | Promise<SpendPermissionState> | Sign and register a spend permission. Use for explicit "Fund listening" UX so the next play() doesn't interrupt for payment. |
| setQueue(trackIds) / queue(trackIds) | void | Replace the current queue. |
| next() / previous() | Promise<void> | Walk the queue. previous() restarts the current track if >3s elapsed. |
| getSpendPermission() | Promise<SpendPermissionState> | Fetch the user's spend permission state from the API. |
| on(event, handler) / off(...) | void | Subscribe/unsubscribe to player events (see table below). |
| getAudioElement() | HTMLAudioElement | Escape hatch for advanced control. |
| destroy() | void | Tear down listeners, audio, and payment state. |
Read-only state
player.state // Readonly<PlayerState> — full snapshot (status, wallet, track, permission, …)
player.track // TrackInfo | null — the currently loaded trackEvents
player.on("stream:progress", ({ currentTime, duration }) => { … });| Event | Payload |
|------------------------|---------------------------------------------------------------------------|
| wallet:connected | { address: string } |
| wallet:disconnected | {} |
| payment:permission | { status: SpendPermissionState["status"]; remainingUsdc: string \| null } |
| payment:required | { trackId: string; price: string } |
| payment:processing | { trackId: string; txHash?: string } |
| payment:confirmed | { trackId: string; chargeId: string; amount: string } |
| payment:failed | { trackId: string; error: string; code: PhaserErrorCode } — branch on code, see Typed errors |
| track:loaded | { trackId: string; title: string; artist: string; duration: number; price: string } |
| stream:started | { trackId: string; title: string } |
| stream:progress | { currentTime: number; duration: number } |
| stream:paused | { trackId: string } |
| stream:resumed | { trackId: string } |
| stream:ended | { trackId: string } |
| error | { code: string; message: string } |
AuthAdapter — bring-your-own wallet
Pass an object that satisfies this interface (e.g. a wrapper around @phaser/auth, wagmi, RainbowKit, or any other wallet stack) to skip the built-in viem wallet flow:
interface AuthAdapter {
readonly address: string | null;
readonly isConnected: boolean;
readonly jwt: string | null;
signMessage(message: string): Promise<string>;
signTypedData(typedData: any): Promise<string>;
sendTransaction(tx: { to: `0x${string}`; data: `0x${string}`; value?: bigint }): Promise<string>;
getBalance(): Promise<string>;
on(event: string, handler: (...args: any[]) => void): void;
off(event: string, handler: (...args: any[]) => void): void;
}
const player = new Player({
auth: myAdapter,
});If auth.jwt is already populated, the player skips the SIWE round-trip and uses it directly to authorize paid streams. Push updates later without tearing down the Player:
player.setAuthToken(newJwt); // after token refresh
player.setAuthToken(null); // on sign-outcreateEip1193Adapter — drop-in adapter for any EIP-1193 provider
Most apps already have an EIP-1193 provider (window.ethereum, Coinbase
Smart Wallet SDK, Rabby, WalletConnect, …). Wrap it in one line:
import { Player, createEip1193Adapter } from "@jackmorgan/phaser-player";
const adapter = createEip1193Adapter({ provider, address, jwt });
const player = new Player({ auth: adapter });The adapter handles the three things consumers otherwise get wrong:
personal_signmessages are hex-encoded (Coinbase Smart Wallet requires it)eth_signTypedData_v4payloads have bigints serialized as decimal stringsEIP712Domainis injected intotypesso strict wallets don't reject it
It also exposes getBalance() (USDC) and getEthBalance() (native ETH) so
your UI can render a wallet view without hitting a public RPC yourself.
runSiwe — Sign-In-With-Ethereum helper
The Phaser API issues JWTs via a four-step handshake (connect → challenge →
sign → verify). runSiwe runs it end-to-end and fires onStep for each
transition so you can drive a progress UI:
import { runSiwe } from "@jackmorgan/phaser-player";
const { jwt, address } = await runSiwe({
provider, // EIP-1193 provider
apiUrl: "https://phaser-api.jackmorgan.xyz",
onStep: ({ step, status }) => updateUI(step, status),
});Steps: connect, challenge, sign, verify. Statuses: active, done,
err. If eth_requestAccounts has already returned, pass address and
runSiwe skips the prompt.
waitForCoinbaseProvider — eager-load synchronization
Coinbase Wallet SDK loads as an async module; browsers drop the user-gesture
context if you await import between a click and eth_requestAccounts,
which blocks the popup. The pattern is to load the SDK eagerly on page
start, attach the provider to window.__coinbaseProvider, then synchronize
with this helper when the user actually clicks sign-in:
import { waitForCoinbaseProvider } from "@jackmorgan/phaser-player";
const provider = await waitForCoinbaseProvider(); // default 3.5s timeout
if (!provider) return toast("Coinbase SDK failed to load");PhaserClient — typed HTTP client
For the endpoints consumers hit outside of player.* — listening history,
likes, playlists, spend-permission queries — use the bundled client. JWT
lifecycle is stateful so you set it once after sign-in:
import { PhaserClient } from "@jackmorgan/phaser-player";
const client = new PhaserClient({ apiUrl });
client.setJwt(jwt); // after sign-in
client.setJwt(null); // on sign-out
const likes = await client.me.likes();
await client.me.like("trk_abc123");
const history = await client.me.history();
const pl = await client.me.createPlaylist("Late night", true);
const perm = await client.payments.getPermission();Non-2xx responses throw PhaserApiError with .status and .body, so
apps can branch on err.status === 401 to trigger a sign-out.
Typed errors
payment:failed carries a stable code: PhaserErrorCode alongside the
human-readable error message. Errors thrown from player.play() /
player.buildSpendPermission() are PhaserError instances with the same
code. Branch on the code, not the message:
import type { PhaserErrorCode } from "@jackmorgan/phaser-player";
try {
await player.play();
} catch (err) {
switch (err.code as PhaserErrorCode) {
case "permission_missing":
case "permission_pending_onchain":
case "balance_low":
case "payment_required": openFundModal(); break;
case "timeout": toast("Payment timed out — try again"); break;
case "signature_declined": toast("You cancelled the signature"); break;
default: toast(`Stream: ${err.message}`, "err");
}
}Full code list: wallet_disconnected | payment_required | permission_missing
| permission_pending_onchain | balance_low | timeout | charge_failed |
authorization_failed | signature_declined | unknown.
Gotchas — read this before shipping
- Counterfactual Coinbase Smart Wallets. An account that hasn't sent any
UserOp has no deployed bytecode, so
SpendPermissionManager.isValidreverts via itsisValidSignaturecall.@base-org/account's multicall fails with"Failed to fetch valid status"/"Failed to fetch current period". The Player tolerates this — the locally-verified signature is trusted until the first UserOp deploys the account — and rewrites the multicall revert tocode: "permission_pending_onchain"onpayment:failed. Your UI should either retry after a short delay or tell the user "wallet deploying, try again in a moment". - Clock skew. The Player backdates spend-permission
startby 5 minutes to dodge Base block-time drift. If you build permissions yourself instead of usingplayer.buildSpendPermission(), do the same — otherwisegetCurrentPeriod()reverts when the user's PC clock is even a few seconds ahead of chain time. - Payment poll timeout.
player.play()polls the charge endpoint for up to 60 seconds before emittingpayment:failedwithcode: "timeout". This is a safety net — backend dispatch issues surface as a clean timeout rather than an indefinite "processing payment…" spinner. - Do not emit
auth:connectedfrom a custom adapter's constructor, even when constructing in an already-connected state. The Player'sWalletModuleAdapteralready handles that synchronously viaauth.isConnected && auth.address. Emitting the event churns internal listeners. - Token refresh.
opts.auth.jwtsurvives wallet reconnects from0.3.0onwards. Push updates viaplayer.setAuthToken(jwt | null)or swap the whole adapter viaplayer.setAuthAdapter(newAdapter | null)— do not destroy and rebuild the Player just to refresh a token.
For a production-grade reference implementation, see
the phaser-app listener and
the wallet integration guide.
Web component
Registering the element is a side-effect of importing the package:
import "@jackmorgan/phaser-player";Attributes map to PlayerOptions:
<phaser-player
track-id="trk_abc123"
api-url="https://phaser-api.jackmorgan.xyz"
theme="dark" <!-- or "light" -->
spend-allowance="5"
spend-period-days="30"
></phaser-player>The underlying Player instance is available as element.player once the component connects.
Compatibility
- Phaser API
v1— the player targets the/v1/*routes onphaser-api.jackmorgan.xyz. A minimum API version will be pinned here if breaking changes land. - Browsers — any evergreen browser with
MediaSourcesupport, plus Safari (uses native HLS). - Node — the package is browser-only; do not
require()from a Node server.
Bundling notes
hls.js and viem are bundled in. If you already ship viem in your app and want to dedupe, import from @jackmorgan/phaser-player as usual — the bundled copy is tree-shakable at the Player level but intentionally not peer-deped to keep the drop-in contract simple. Open an issue if you need a viem-peer build.
License
MIT
