Skip to content

feat(web): play a set's recording with its tracklist following along - #274

Merged
botandbang merged 7 commits into
mainfrom
bnb/mix-player
Oct 29, 2026
Merged

botandbang merged 7 commits into
mainfrom
bnb/mix-player

Conversation

@botandbang

@botandbang botandbang commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Sets on Local Network can now be played on the site, with their tracklist following along.

Added:

  • A player (apps/web/src/lib/components/mix/SetPlayer.svelte): plays a set's recording and shows the track playing now, with play/pause, previous and next track, and a position bar marked at each track's start. It feeds the Media Session API: the track's title and artist, the set's name, plyr.fm artwork, and play, pause, seek and track actions.
  • A Play button on each set in DjSets.svelte that has a recording or a playable link. While the player is open, the set's tracklist unfolds, marks the track playing, and starts any track from its time or its row.
  • On the mix report page (/projects/<slug>/mixes/<n>), the player replaces the platform embed. Picking a track in the timeline, tracklist, key wheel or tempo chart plays from it, and the tracklist marks the track playing.
  • GET /sets/<did>/<rkey>/player.json, which picks where a set plays when someone presses Play (apps/web/src/lib/server/services/recordings.ts). It prefers audio the page plays itself over a platform's player: the set's recording, either an fm.plyr.track through plyr.fm's public API (apps/web/src/lib/server/plyr.ts: its file on audio.plyr.fm, or plyr's embed player when the track is gated) or a computer.localnetwork.dj.recording on the DJ's PDS; then its links, where archive.org downloads play directly and SoundCloud, Mixcloud, YouTube and plyr.fm use their own players. Mixcloud's and SoundCloud's widget APIs (apps/web/src/lib/mix/widgets.ts) let the tracklist follow those players too.
  • GET /media/audio/<did>/<cid> (apps/web/src/lib/server/media/audio.ts), which streams a recording's blob from the DJ's PDS: only a blob referenced by an indexed dj.recording of an active project's manager, as the type and size that record declares, within the lexicon's accept list and maxSize (read from lexicons/computer/localnetwork/dj/recording.json). A video/mp4 blob, which a PDS reports for an M4A without its audio brand, goes out as audio/mp4. It answers Range itself, since getBlob ignores it, and stores nothing. HappyView doesn't serve blob bytes, so this read goes to the PDS with an ast-grep-ignore saying why.
  • GET /sets/<did>/<rkey>/chapters.json, a set's tracklist as Podcasting 2.0 JSON chapters (apps/web/src/lib/mix/chapters.ts).

Changed:

  • embedUrl in apps/web/src/lib/mix/links.ts turns plyr.fm track pages into plyr's embed player.
  • The CSP in apps/web/vite.config.ts adds a media-src for the site, audio.plyr.fm and archive.org's download hosts, adds widget.mixcloud.com and w.soundcloud.com to script-src, and plyr.fm to frame-src.
  • MixEmbed.svelte exposes its iframe for the widget APIs.
  • apps/web/src/lib/server/audio.ts holds the recording types and size from the lexicon and builds the audio response, refusing other types and serving video/mp4 as audio/mp4, so /media/audio passes tools/ast-grep/rules/stored-type-response.yml the way images.ts does; the rule ignores both helpers. The chapters route's content type is a literal.
  • apps/web/README.md describes the player, the audio route and the CSP.

The client JavaScript comes to 635.1 KiB, within main's 640 KiB budget.

How it was tested

  • Unit tests for choosing a source (source.test.ts, recordings.test.ts, plyr.test.ts), the audio route over a fake PDS (audio.test.ts: ranges across chunks, 416, refusing unreferenced CIDs, non-audio and oversize records and non-managers, the exact 300 MiB boundary, video/mp4 served as audio/mp4, the upstream read stopping after the range), and the chapters (chapters.test.ts). The recordings tests index their records through the real dj/recording.lua gate.
  • Browser tests in set-player.svelte.test.ts: the track playing as the recording plays on, seeking from the position bar and from the page, the keyboard, Media Session metadata, errors and retry, following Mixcloud's and SoundCloud's players, DjSets and the mix report.
  • Storybook stories for each state: loading, a PDS recording, playing, a plyr.fm track, an archive.org download, a platform's player only, won't play, failing audio, and a phone.
  • By hand on pnpm dev, at 390 and 1280 px: a demo set given an archive.org link played and seeked under the CSP, with the track in the system's media controls; the SoundCloud player and the mix report page opened; axe found no violations.
  • Each new test was checked by breaking the code it covers.
  • pnpm -C apps/web verify, pnpm -C apps/web test:coverage (lines 94.68, statements 93.15, functions 93.59, branches 83.33), the root pnpm verify and ast-grep scan pass.

Screenshots

Phone (390 px) and wide (1280 px), from the dev site and Storybook, to attach before review. The site has no light theme on screen, and the player is hidden in print.

Checklist

  • Tests cover the change
  • For the website: Storybook stories cover any new UI states
  • For locallibrary: apps/locallibrary/CHANGELOG.md has a line under Unreleased, if people using it will notice
  • The READMEs, docs/ and --help still say what the code does

A set with a recording or a playable link gets a Play button in DjSets.
Pressing it asks /sets/<did>/<rkey>/player.json where it plays, and the
server picks, audio first: the set's recording record (an fm.plyr.track,
from plyr.fm's CDN through its public API, or plyr's embed player when the
track is gated; or a computer.localnetwork.dj.recording on the DJ's PDS),
then its links (archive.org downloads play directly, the platforms in their
own players). SetPlayer marks the track playing in the tracklist, starts
a track when its start time or row is clicked, skips between tracks, and
feeds the Media Session API. SoundCloud's and Mixcloud's widget APIs let
it follow those players too.

The mix report page streams the linked set's source, falling back to the
mix's link, and picking a track there plays from it.

/media/audio/<did>/<cid> streams a recording's blob from the DJ's PDS:
only one an indexed dj.recording of an active project's manager
references, as the audio type and size it declares, answering Range
itself since getBlob ignores it. It stores nothing.

/sets/<did>/<rkey>/chapters.json serves a set's tracklist as Podcasting
2.0 JSON chapters. embedUrl knows plyr.fm track pages, and the CSP adds
media-src and the widget and plyr.fm hosts.

The client bundle budget in tests/build.test.ts goes from 620 to 640 KiB
of JavaScript: the player adds about 15 KB to every page's code together
(628,219 bytes before, 643,120 after), and the build was already within
7 KB of the old limit.
# Conflicts:
#	apps/web/src/lib/nsid.ts
The set player's PDS recordings were written before
computer.localnetwork.dj.recording existed, and disagreed with it in
three places.

The size cap was 300,000,000 bytes while the lexicon's audio maxSize is
314,572,800 (300 MiB), so a recording locallibrary uploaded near the cap
wouldn't play. RECORDING_MAX_BYTES is now read from the lexicon file,
and so is the accepted type list, matched with @atproto/oauth-scopes'
BlobPermission the way a PDS matches a blob scope.

That list takes video/mp4 besides audio/*: a PDS names a blob's type by
sniffing it, and an M4A that doesn't brand itself as audio comes back
as video/mp4. Those recordings now play, and /media/audio/<did>/<cid>
sends them as audio/mp4.

durationSeconds is optional in the lexicon and the player reads the
length from the audio, so the schema no longer parses it.

The recording service tests now index their records through HappyView's
real dj/recording.lua gate instead of seeding them as written, and cover
a video/mp4 recording, one at exactly 300 MiB and one a byte over, and
one without durationSeconds. The README describes the audio route's
limits as the lexicon's.
@github-actions github-actions Bot added documentation Improvements or additions to documentation web The Local Network website (apps/web) web: projects Project profiles, claims, and the DJ stats and sets on them web: mixes Mixes, their tracklists and mix reports web: server Spaces, sync, HappyView, the database and the server around them web: ui Shared components, design tokens, layout and the static pages tests Tests, fixtures and test harnesses tooling Repo config, lint and build tooling, and GitHub settings labels Oct 28, 2026
bnb
bnb previously approved these changes Oct 29, 2026
Base automatically changed from bnb/pds-recordings to main October 29, 2026 17:49
@botandbang
botandbang dismissed bnb’s stale review October 29, 2026 17:49

The base branch was changed.

Main's stored-type-response ast-grep rule fails any response in
apps/web/src whose content type comes from data. The set player's
recording route set its type from the recording record, and the
chapters route from an imported constant, so both tripped it.

apps/web/src/lib/server/audio.ts sits beside images.ts and holds what
the recording lexicon says about audio: RECORDING_TYPES, its accept
list (audio/* and video/mp4) as the BlobPermission matcher the
recording service already used, and RECORDING_MAX_BYTES, its maxSize.
Its audioResponse lowercases the type, answers a plain 404 and cancels
the body for a type outside that list, sends video/mp4 as audio/mp4,
and sets the CSP and nosniff the route already sent.
media/audio.ts keeps its Range, 206 and 416 handling, content-length,
etag and cache-control, and hands the body and type to audioResponse.
The recording service reads its type list and size cap from the new
module instead of its own copies.

The chapters route now writes application/json+chapters as a literal,
and the CHAPTERS_TYPE constant it was the only user of is gone.

The rule ignores audio.ts as it does images.ts, its note names both
helpers, and its tests take an audioResponse call as valid. New tests
cover the accepted types and their edges (video/webm, audiox/mpeg,
audio/*), the 300 MiB cap, case, the video/mp4 mapping and refusals;
the route's tests check a refused type cancels the PDS read. The README
lists the rule after both stored-media bullets.
@botandbang
botandbang marked this pull request as ready for review October 29, 2026 19:02
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Coverage Report for apps/web

Status Category Percentage Covered / Total
🟢 Lines 94.68% (🎯 93%)
⬆️ +0.05 pp
6446 / 6808
🟢 Statements 93.14% (🎯 91%)
⬆️ +0.17 pp
9905 / 10634
🟢 Functions 93.56% (🎯 92%)
⬇️ -0.02 pp
2528 / 2702
🟢 Branches 83.33% (🎯 80%)
⬆️ +0.29 pp
4644 / 5573
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/web/src/lib/nsid.ts 100%
🟰 ±0 pp
100%
🟰 ±0 pp
100%
🟰 ±0 pp
100%
🟰 ±0 pp
apps/web/src/lib/components/DjSets.svelte 98.62%
⬇️ -1.38 pp
89.33%
⬆️ +3.22 pp
98.11%
⬇️ -1.89 pp
98.21%
⬇️ -1.79 pp
32, 46
apps/web/src/lib/components/mix/MixEmbed.svelte 100%
⬆️ +100.00 pp
100%
🟰 ±0 pp
100%
⬆️ +100.00 pp
100%
⬆️ +100.00 pp
apps/web/src/lib/components/mix/MixReport.svelte 94.4%
⬆️ +9.60 pp
69.44%
⬆️ +6.76 pp
98.14%
⬆️ +12.43 pp
98.3%
⬆️ +4.07 pp
93, 93, 125, 149, 146
apps/web/src/lib/components/mix/MixTracklist.svelte 92.74%
⬆️ +0.81 pp
65.34%
⬆️ +4.05 pp
93.47%
⬆️ +2.57 pp
94.82%
⬆️ +1.72 pp
8, 58, 58, 78, 78, 51-53
apps/web/src/lib/components/mix/SetPlayer.svelte 95.37% 72.26% 88.05% 96.06% 66, 96, 121, 133, 151-155, 268
apps/web/src/lib/components/mix/player-fixtures.ts 100% 100% 100% 100%
apps/web/src/lib/mix/chapters.ts 100% 100% 100% 100%
apps/web/src/lib/mix/links.ts 100%
🟰 ±0 pp
100%
🟰 ±0 pp
100%
🟰 ±0 pp
100%
🟰 ±0 pp
apps/web/src/lib/mix/source.ts 100% 93.54% 100% 100%
apps/web/src/lib/mix/widgets.ts 65.67% 40% 68% 69.38% 64-85, 93, 94, 121, 122
apps/web/src/lib/server/app.ts 69.73%
⬇️ -0.73 pp
46.8%
🟰 ±0 pp
57.77%
⬇️ -2.69 pp
76.92%
⬇️ -0.42 pp
80, 112, 138, 140, 143, 165, 210-223, 287, 297, 304, 307-308, 320, 342, 359-381, 385, 394-397, 407-412
apps/web/src/lib/server/audio.ts 100% 100% 100% 100%
apps/web/src/lib/server/plyr.ts 100% 100% 100% 100%
apps/web/src/lib/server/media/audio.ts 100% 100% 100% 100%
apps/web/src/lib/server/services/recordings.ts 95.77% 95.34% 100% 96.66% 57, 118-120
Generated in workflow #545 for commit fd75a55 by the Vitest Coverage Report Action

Size

Compared with main. KiB are 1,024 bytes.

This pull request main Change
Client JavaScript 635.1 KiB 620.4 KiB +14.7 KiB (+2.4%)
Client JavaScript, brotli 209.5 KiB 204.8 KiB +4.6 KiB (+2.2%)
Client CSS 88.6 KiB 87.8 KiB +750 B (+0.8%)
Client CSS, brotli 12.7 KiB 12.6 KiB +151 B (+1.2%)

@botandbang
botandbang merged commit 1530eb8 into main Oct 29, 2026
50 of 71 checks passed
@botandbang
botandbang deleted the bnb/mix-player branch October 29, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests Tests, fixtures and test harnesses tooling Repo config, lint and build tooling, and GitHub settings web: mixes Mixes, their tracklists and mix reports web: projects Project profiles, claims, and the DJ stats and sets on them web: server Spaces, sync, HappyView, the database and the server around them web: ui Shared components, design tokens, layout and the static pages web The Local Network website (apps/web)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants