Phaser — Developer Docs
Phaser is a web3 music streaming network: artists publish HLS streams, listeners pay per stream in USDC on Base through a frictionless spend-permission flow.
This page covers everything you need to ship a Phaser-powered app. Two npm packages (@jackmorgan/phaser-player, @jackmorgan/phaser-catalog) plus a REST API at https://phaser-api.jackmorgan.xyz.
Architecture at a glance
┌───────────────────────────────┐
│ Phaser REST API │
│ phaser-api.jackmorgan.xyz │
│ (Hono · Neon · S3) │
└──────────┬────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────────┐ ┌──────────────────────┐
│ @jackmorgan/phaser-player │ │ @jackmorgan/phaser-catalog │ │ Direct REST │
│ │ │ │ │ (any language) │
│ HLS playback + │ │ Typed client for │ │ │
│ wallet-gated streams │ │ tracks/artists/etc │ │ GET /v1/* │
│ (viem + hls.js) │ │ (zero deps) │ │ │
└──────────────────────────────┘ └───────────────────────────────┘ └──────────────────────┘| Piece | Install | Use it when… |
|---|---|---|
@jackmorgan/phaser-player | npm install @jackmorgan/phaser-player | you want to play Phaser tracks (headless or drop-in web component) |
@jackmorgan/phaser-catalog | npm install @jackmorgan/phaser-catalog | you want to browse/search the catalog without hand-rolling fetch calls |
| REST API | — | you're building in a language that doesn't have a Phaser SDK yet |
@jackmorgan/phaser-player
Embeddable HLS music player. Handles wallet connection, USDC payment via spend permissions, and HLS playback in one self-contained bundle. hls.js and viem are bundled, so there's nothing to peer-dep.
Two entry points:
Player— headless class, bring your own UI<phaser-player>— web component with a themeable default UI
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>Web component (easiest)
<script type="module">
import "@jackmorgan/phaser-player";
</script>
<phaser-player
track-id="trk_abc123"
theme="dark"
></phaser-player>Attributes map 1:1 to PlayerOptions. The underlying Player instance is available as element.player once connected.
Headless
import { Player } from "@jackmorgan/phaser-player";
const player = new Player();
player.on("track:loaded", (e) => console.log(e.title));
player.on("stream:progress", (e) => console.log(e.currentTime));
await player.load("trk_abc123");
await player.connectWallet(); // paid tracks require a wallet first
await player.play();Player API
| Method | Returns | Notes |
|---|---|---|
load(trackId) | Promise<TrackInfo> | fetches metadata |
play() | Promise<void> | handles payment + HLS start |
pause() / resume() / stop() | void | |
seek(seconds) / setVolume(0..1) | void | |
connectWallet() | Promise<string> | returns the address |
disconnectWallet() / getBalance() | — | |
setQueue(ids) · next() · previous() | — | queue walks; previous() restarts if >3s elapsed |
getSpendPermission() | Promise<SpendPermissionState> | |
on(event, handler) / off(...) | — | see event table below |
destroy() | void | tear down |
Read-only state: player.state (full snapshot), player.track (current TrackInfo \| null).
Events
player.on("stream:progress", ({ currentTime, duration }) => { … });| Event | Payload |
|---|---|
wallet:connected | { address } |
wallet:disconnected | {} |
payment:required | { trackId, price } |
payment:processing | { trackId, txHash? } |
payment:confirmed | { trackId, chargeId, amount } |
payment:failed | { trackId, error } |
payment:permission | { status, remainingUsdc } |
track:loaded | { trackId, title, artist, duration, price } |
stream:started | { trackId, title } |
stream:progress | { currentTime, duration } |
stream:paused / stream:resumed / stream:ended | { trackId } |
error | { code, message } |
Bring-your-own wallet
If you already have a wallet flow (wagmi, RainbowKit, your own stack), pass an AuthAdapter to skip the built-in viem module:
const player = new Player({
auth: myAdapter, // must satisfy the AuthAdapter interface
});Minimum AuthAdapter shape (full doc in the package README):
interface AuthAdapter {
readonly address: string | null;
readonly isConnected: boolean;
readonly jwt: string | null;
signMessage(msg: string): Promise<string>;
signTypedData(typed: any): Promise<string>;
sendTransaction(tx: {...}): Promise<string>;
getBalance(): Promise<string>;
on(ev, fn): void;
off(ev, fn): void;
}If auth.jwt is already populated, the player skips its SIWE round-trip.
@jackmorgan/phaser-catalog
Typed REST client. Zero runtime deps. Works in Node 20+ and every modern browser (uses global fetch).
Install
npm install @jackmorgan/phaser-catalogQuick start
import { CatalogClient } from "@jackmorgan/phaser-catalog";
const catalog = new CatalogClient();
const page = await catalog.tracks({ q: "neon", sort: "newest", limit: 25 });
const genres = await catalog.genres();
const results = await catalog.search("daft punk");Methods
Every method accepts an optional AbortSignal.
catalog.tracks({ q?, genre?, artistId?, sort?, cursor?, limit? }) // → Paginated<Track>
catalog.track(id) // → Track
catalog.artists({ q?, cursor?, limit? }) // → Paginated<Artist>
catalog.artist(id) // → Artist
catalog.artistTracks(id, { cursor?, limit? }) // → Paginated<Track>
catalog.album(id) // → Album
catalog.genres() // → Genre[]
catalog.search(q, { limit? }) // → SearchResult
catalog.playlist(id) // → Playlistsort accepts "newest" | "popular". All paginated responses are { data: T[]; next_cursor: string \| null; has_more: boolean }.
Errors
Non-2xx responses throw a typed error:
import {
CatalogError,
CatalogAuthError,
CatalogNotFoundError,
CatalogRateLimitError,
} from "@jackmorgan/phaser-catalog";
try {
await catalog.track("trk_missing");
} catch (err) {
if (err instanceof CatalogNotFoundError) { /* 404 */ }
else if (err instanceof CatalogAuthError) { /* 401 / 403 */ }
else if (err instanceof CatalogRateLimitError) { console.log(err.retryAfter); }
else if (err instanceof CatalogError) { console.log(err.status, err.code); }
}Every error carries { status, code, message, url?, body? }. Network failures (DNS, reset) propagate as the underlying TypeError / AbortError from fetch.
Cancellation
const ac = new AbortController();
const promise = catalog.tracks({ q: "neon", signal: ac.signal });
ac.abort();Pairing with the player
import { CatalogClient } from "@jackmorgan/phaser-catalog";
import { Player } from "@jackmorgan/phaser-player";
const catalog = new CatalogClient();
const player = new Player();
const page = await catalog.tracks({ sort: "newest" });
await player.load(page.data[0].id);
await player.play();REST API
Base URL: https://phaser-api.jackmorgan.xyz. All endpoints live under /v1/*.
Auth
Public catalog and streaming endpoints are open — no header required.
| Header | Value |
|---|---|
Authorization: Bearer <jwt> | SIWE JWT — required on /v1/me/* and artist upload routes |
Response shape
Paginated endpoints: { data: T[], next_cursor: string \| null, has_more: boolean }. Single-resource endpoints return the resource directly. Errors: { error: string, code?: string } with an HTTP status code.
Endpoints (public)
| Method | Path | Purpose |
|---|---|---|
GET | /v1/tracks | list tracks (q, genre, artist_id, sort, cursor, limit) |
GET | /v1/tracks/:id | single track |
GET | /v1/artists | list artists |
GET | /v1/artists/:id | single artist |
GET | /v1/artists/:id/tracks | tracks by artist |
GET | /v1/albums/:id | single album (with tracks) |
GET | /v1/genres | genre list |
GET | /v1/search?q=… | tracks + artists + albums |
GET | /v1/playlists/:id | single playlist (public only) |
Endpoints (auth — SIWE)
| Method | Path | Purpose |
|---|---|---|
POST | /v1/auth/challenge | fetch SIWE message to sign |
POST | /v1/auth/verify | submit signature → returns JWT |
GET | /v1/me/likes | the caller's liked tracks |
PUT | /v1/me/likes/:trackId | like a track |
DELETE | /v1/me/likes/:trackId | unlike |
GET / POST | /v1/me/playlists | playlist CRUD (see packages/api/src/routes/me.ts) |
Endpoints (streaming + uploads)
| Method | Path | Purpose |
|---|---|---|
POST | /v1/stream/:trackId/authorize | charges the spend permission, returns an HLS URL |
GET | /v1/stream/…/playlist.m3u8 | HLS manifest (token-gated) |
POST | /v1/upload/draft | artist: create a draft track |
POST | /v1/upload/:id/presign | artist: S3 presign for the audio file |
POST | /v1/upload/:id/finalize | artist: transcode + publish |
Day-to-day consumers almost never hit streaming/upload endpoints directly — the player does the streaming handshake for you, and the upload flow is a dashboard feature. They're documented here so you know what's available.
Example — cURL
# List 10 newest tracks
curl "https://phaser-api.jackmorgan.xyz/v1/tracks?sort=newest&limit=10"
# Search
curl "https://phaser-api.jackmorgan.xyz/v1/search?q=daft+punk"Support
- npm packages: @jackmorgan/phaser-player · @jackmorgan/phaser-catalog
- Source & issues: https://github.com/jackmorganxyz/phaser
- Email: contact@jackmorgan.xyz