RFC: plyr.fm JavaScript SDK & Embeddable Web Components
Authors: Nate Spilman, Claude 4.6 Opus
Status: Draft
Date: 2026-03-15
1. Summary
This RFC proposes a JavaScript/TypeScript package that provides:
The headless SDK enables developers to build custom music UIs on top of plyr.fm data. The Web Components provide the canonical plyr.fm player experience as embeddable elements that work in React, Vue, Svelte, plain HTML, and any framework that renders DOM.
This mirrors the existing architecture: the Python SDK plyrfm on PyPI) provides API access, while plyr.fm‘s iframe embeds provide branded players. This package unifies both into a single JS distribution.
2. Motivation
What exists today
- iframe embeds plyr.fm/embed/track/{id}, /embed/playlist/{id}, /embed/album/{handle}/{slug}) — work everywhere, zero dependencies, but no customization, no data access, cross-origin isolation
- Python SDK plyrfm on PyPI) — typed client with CLI support. A companion MCP server package plyrfm-mcp) exists in the same repo zzstoatzz/plyr-python-client). Covers API access but not rendering
- Public API api.plyr.fm) — rich set of unauthenticated endpoints for tracks, albums, playlists, artists, search, audio streaming, oEmbed, and RSS feeds
What’s missing
- No JS/TS SDK — frontend developers and Node.js services have no typed client. Anyone building on plyr.fm data in JavaScript is writing raw fetch calls
- No customizable embed — the iframe is take-it-or-leave-it. Developers can’t change colors, layout, or behavior. They can’t integrate playback into their own UI
- No headless audio — there’s no JS package that handles the plyr.fm audio playback lifecycle (URL resolution, play counting, gated content detection, lossless format negotiation)
Who benefits
- Third-party ATProto clients building music features (e.g., a Bluesky client that renders fm.plyr.track records inline)
- Artist websites wanting a branded-but-customizable player
- Developers building music discovery tools, dashboards, or bots in JavaScript
- The plyr.fm ecosystem — more surfaces playing plyr.fm audio = more value for artists on the platform
3. Public API Surface (what the SDK wraps)
Based on live testing of api.plyr.fm (March 2026), these endpoints are public (no auth required) and form the SDK’s read surface:
Tracks
Method | Path | Description |
|---|---|---|
GET | /tracks/ | List tracks (cursor-based pagination, optional artist_did filter) |
GET | /tracks/top | Top tracks by play count |
GET | /tracks/{id} | Single track with metadata, counts, tags |
POST | /tracks/{id}/play | Increment play count (client decides when to fire — threshold TBD) |
Albums
Method | Path | Description |
|---|---|---|
GET | /albums/ | List all albums |
GET | /albums/{handle} | Albums by artist |
GET | /albums/{handle}/{slug} | Album detail with ordered tracks |
Playlists
Method | Path | Description |
|---|---|---|
GET | /lists/playlists/{id} | Playlist with tracks |
Artists
Method | Path | Description |
|---|---|---|
GET | /artists/by-handle/{handle} | Artist profile by handle |
GET | /artists/{did} | Artist profile by DID |
GET | /artists/{did}/analytics | Public analytics (plays, top tracks) |
POST | /artists/batch | Batch artist lookup by DIDs |
Search
Method | Path | Description |
|---|---|---|
GET | /search/ | Unified search (tracks, artists, albums, tags, playlists) |
GET | /search/semantic | CLAP embedding mood search |
Audio
Method | Path | Description |
|---|---|---|
GET | /audio/{file_id} | 307 redirect to R2 CDN or PDS blob URL |
HEAD | /audio/{file_id} | Returns 307 redirect for accessible content; may return 401/402 for gated content (not yet verified) |
Other
Method | Path | Description |
|---|---|---|
GET | /oembed | oEmbed JSON for track/playlist/album URLs |
GET | /feeds/artist/{handle} | RSS feed (currently returning 500 — may be temporarily broken) |
GET | /feeds/album/{handle}/{slug} | Album RSS feed (currently returning 500 — may be temporarily broken) |
GET | /feeds/playlist/{id} | Playlist RSS feed (verified working) |
GET | /jams/{code}/preview | Jam session preview |
CORS consideration
As of March 2026, the API reflects the requesting Origin header back in access-control-allow-origin with access-control-allow-credentials: true. This means CORS is currently open to all origins — browser-based SDK usage from third-party domains works today. Whether this policy is intentional and will be maintained is worth confirming with the backend team (see Section 8).
4. Proposed Package Design
4.1 Package structure
We propose a single package with multiple entrypoints:
plyrfm/ # or @plyr/sdk — naming TBD
├── src/
│ ├── client.ts # PlyrClient class
│ ├── types.ts # TypeScript interfaces (Track, Album, Playlist, Artist, etc.)
│ ├── audio.ts # Audio playback utilities (play counting, lossless detection, gated checks)
│ ├── index.ts # Headless SDK entrypoint
│ └── components/
│ ├── plyr-track.ts # <plyr-track> Web Component
│ ├── plyr-playlist.ts # <plyr-playlist> Web Component
│ ├── plyr-album.ts # <plyr-album> Web Component
│ ├── styles.ts # Shared CSS (design tokens, container queries)
│ └── index.ts # Web Components entrypoint
├── package.json
├── tsconfig.json
└── README.md
Entrypoints:
{
"exports": {
".": "./dist/index.js",
"./components": "./dist/components/index.js"
}
}
Usage — headless:
import { PlyrClient } from 'plyrfm'
const plyr = new PlyrClient()
const track = await plyr.getTrack(795)
console.log(track.title, track.r2_url)
Usage — branded components:
<script type="module">
import 'plyrfm/components'
</script>
<plyr-track track-id="795"></plyr-track>
<plyr-playlist playlist-id="8ec653a8-1166-4f2f-80db-93b7717e8410"></plyr-playlist>
<plyr-album handle="goose.art" slug="fuck"></plyr-album>
4.2 Headless SDK plyrfm)
Mirrors the Python SDK’s class-based design:
class PlyrClient {
constructor(options?: { apiUrl?: string; token?: string })
// Tracks
listTracks(options?: { limit?: number; cursor?: string; artistDid?: string }): Promise<TracksListResponse>
topTracks(options?: { limit?: number }): Promise<Track[]>
getTrack(id: number): Promise<Track>
reportPlay(id: number, options?: { ref?: string }): Promise<{ play_count: number }>
// Albums
listAlbums(): Promise<Album[]>
getArtistAlbums(handle: string): Promise<Album[]>
getAlbum(handle: string, slug: string): Promise<AlbumResponse>
// Playlists
getPlaylist(id: string): Promise<PlaylistWithTracks>
// Artists
getArtist(handleOrDid: string): Promise<Artist>
getArtistAnalytics(did: string): Promise<ArtistAnalytics>
batchArtists(dids: string[]): Promise<Record<string, Artist>>
// Search
search(query: string, options?: { type?: string; limit?: number }): Promise<SearchResponse>
semanticSearch(query: string, options?: { limit?: number }): Promise<SemanticSearchResponse>
// Audio utilities
getAudioUrl(fileId: string): Promise<string>
checkGatedAccess(fileId: string): Promise<'ok' | 'auth-required' | 'supporter-required'>
// oEmbed
getOEmbed(url: string, options?: { maxwidth?: number; maxheight?: number }): Promise<OEmbedResponse>
}
Types derived from the live API response shape (verified against GET /tracks/795, March 2026):
interface Track {
id: number
title: string
artist: string
artist_handle: string
artist_did?: string
artist_avatar_url?: string
file_id: string
file_type: string
features: unknown[]
r2_url?: string
image_url?: string
thumbnail_url?: string
play_count: number
like_count?: number
comment_count?: number
tags?: string[]
album?: AlbumSummary | null
gated?: boolean
support_gate?: SupportGate | null
original_file_id?: string | null
original_file_type?: string | null
description?: string | null
audio_storage?: 'r2' | 'pds' | 'both'
created_at?: string
atproto_record_uri?: string
atproto_record_cid?: string
atproto_record_url?: string | null
is_liked?: boolean
copyright_flagged?: boolean | null
copyright_match?: string | null
pds_blob_cid?: string
}
4.3 Audio playback utilities plyrfm/audio)
Proposed audio utility functions (implementation details to be informed by the plyr.fm frontend source, which is a SvelteKit app):
// Play count tracking — fires POST /tracks/{id}/play after threshold
function createPlayCounter(client: PlyrClient): PlayCounter
// Lossless format detection — checks browser canPlayType
function canPlayLossless(fileType: string): boolean
// Gated content check — request to /audio/{fileId} (exact gating behavior TBD — needs verification with a gated track)
async function checkAccess(fileId: string): Promise<'ok' | 'auth-required' | 'supporter-required'>
// Resolve best audio URL for a track (prefer lossless if supported)
function resolveAudioSrc(track: Track): string
4.4 Branded Web Components
Custom elements built on the Web Components standard (no framework dependency). Each component:
- Fetches data from the API via the headless SDK
- Renders Shadow DOM with the plyr.fm embed UI
- Uses CSS container queries for responsive layout (same breakpoints as existing embeds)
- Manages an internal element for playback
*
<plyr-track
track-id="795"
autoplay
accent-color="#6a9fff"
></plyr-track>
Attributes:
- track-id (required) — track ID
- autoplay — auto-play on mount
- accent-color — override accent color (default #6a9fff)
- api-url — override API base URL
*
<plyr-playlist
playlist-id="8ec653a8-1166-4f2f-80db-93b7717e8410"
></plyr-playlist>
Attributes:
- playlist-id (required) — playlist UUID
- autoplay, accent-color, api-url
*
<plyr-album
handle="goose.art"
slug="fuck"
></plyr-album>
Attributes:
- handle (required) — artist handle
- slug (required) — album slug
- autoplay, accent-color, api-url
Events emitted:
- plyr:play — playback started (detail: { track })
- plyr:pause — playback paused
- plyr:ended — track ended
- plyr:trackchange — track changed in collection (detail: { track, index })
- plyr:error — error loading track or audio
Imperative API:
const el = document.querySelector('plyr-track')
el.play()
el.pause()
el.currentTime = 30
el.volume = 0.5
4.5 CSS architecture for Web Components
Web Components use Shadow DOM, so styles are encapsulated by default — no leaking, no conflicts.
Key design decisions:
- All CSS lives inside Shadow DOM (no external stylesheet dependency)
- Container query breakpoints should match existing embeds (exact values TBD — requires access to the plyr.fm frontend source)
- Accent color exposed as CSS custom property via accent-color attribute → —plyr-accent
- Font stack matches plyr.fm: ‘SF Mono’, ‘Monaco’, ‘Inconsolata’, ‘Fira Code’, ‘Consolas’, monospace
- Dark theme only (matches existing embeds; light theme is a future consideration)
5. Implementation approach options
Option A: Vanilla Web Components (no build-time framework)
Write custom elements directly using the HTMLElement API with manual DOM construction and reactive updates.
Pros: Zero dependencies, smallest bundle, no framework lock-in
Cons: Verbose, manual DOM diffing, harder to maintain the responsive layout logic
Option B: Lit
Use Google’s Lit library for Web Components. Lit provides reactive properties, template literals, and lifecycle hooks with ~5KB overhead.
Pros: Good DX (reactive properties, html tagged templates), small footprint, widely adopted, great TS support
Cons: Adds a dependency, slightly larger bundle than vanilla
Option C: Svelte compiled to Web Components
Svelte can compile components to custom elements via customElement: true. Svelte can compile components to custom elements via customElement: true. The plyr.fm frontend is a SvelteKit app (confirmed via x-sveltekit-page response header on embed routes), so this could potentially share source.
Pros: Closest to existing frontend stack, could potentially share source with plyr.fm frontend, excellent reactivity
Cons: Svelte CE compilation has known quirks (style encapsulation, SSR, slot handling), ties the package to Svelte’s compiler, larger bundle per component
Recommendation
Option B (Lit) balances DX, bundle size, and maintainability. The embed UI is relatively contained — two component variants (track + collection) with responsive CSS. Lit’s reactive properties map cleanly to Web Component attributes, and the css tagged template handles Shadow DOM styles well.
If the team prefers zero dependencies, Option A is viable given the limited component surface area.
6. Relationship to existing plyr.fm embeds
The Web Components would not replace the iframe embeds. Both serve different use cases:
iframe embed | Web Component | |
|---|---|---|
Zero setup | Yes (paste URL) | Requires |