RFC: plyr.fm JavaScript SDK & Embeddable Web Components

By@Nate SpilmanMar 15, 2026
Subscribe

Authors: Nate Spilman, Claude 4.6 Opus
Status: Draft
Date: 2026-03-15


1. Summary

This RFC proposes a JavaScript/TypeScript package that provides:

  1. Headless SDK — typed client for the plyr.fm public API (tracks, albums, playlists, artists, search, audio URLs)
  2. Branded Web Components — drop-in , , and custom elements that render the plyr.fm player UI in any HTML context

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:

  1. Fetches data from the API via the headless SDK
  2. Renders Shadow DOM with the plyr.fm embed UI
  3. Uses CSS container queries for responsive layout (same breakpoints as existing embeds)
  4. 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