Files
ytplayer/CLAUDE.md

34 KiB
Raw Blame History

ytplayer — project facts for agents

What this is

Ad-free YouTube player. Three shells share frontend/: web PWA (Bun + Hono in server/, the production deployment), Tauri (Windows), zero-native (Linux/macOS). The PWA is what runs in production; legacy/ holds the old native-only docs.

Repo map

Path What lives there
frontend/app.js The entire UI (~3.6k lines, no framework): player, playlists, search, settings, sync
frontend/sw.js + frontend/sw-update.js Service worker + update flow (FRAGILE — see below)
frontend/opfs.js / opfs-worker.js OPFS offline audio cache
frontend/fingerprint.js Browser fingerprint used as the sync key
frontend/async-guard.js Stale-async-response guard (unit-tested)
server/server.js Bun + Hono backend — endpoint list is in its header comment
server/db.js libsql schema/queries (users, profiles, video_history, media_cache…)
server/media-cache.js Server-side video cache: background fetch jobs, validation gate, x264 lane, LRU
bin/yt-dlp Downloaded by npm run setup, gitignored
scripts/ icon generation, yt-dlp setup, push helper
tests/ Playwright e2e specs · unit tests live next to sources in frontend/*.test.js
legacy/ Old native-shell docs — do not treat as current

API endpoints (notes/admin routes are listed in the header of server/notes.js): GET /api/search|channel|streams|download/:id|version|user/data|profile/load, GET /api/media/:id|media/:id/status|media/stats, POST /api/media/:id/redownload, POST /api/user/sync|profile/create|profile/save, GET /sw.js (BUILD_TAG-injected), GET /* static. JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone (/api/streams' data.serverCached is additive and web-only).

Server media cache (server/media-cache.js)

  • Every played (/api/streams, LOW priority, ≤ MEDIA_AUTO_MAX_SECONDS) or saved (/api/download, HIGH, ≤ 3 h) video gets ONE copy: $MEDIA_DIR/<id>.<gen>.mp4 (≤720p H.264 8-bit + AAC, faststart) + <id>.<gen>.m4a audio sidecar for audio-only mode. MEDIA_DIR defaults to ./data/media (the ytplayer-data volume in prod).
  • Jobs are server-owned — never pass a request AbortSignal into them; a client closing its tab must not kill a fetch. Downloads still run inside withSaveSlot.
  • Validation gate (validateMedia): h264 yuv420p + AAC, container/stream durations cover the source, a full -c copy demux pass with zero stderr, head + tail decode. The demux pass is what catches a faststart file whose index is intact but whose data is cut. Nothing reaches the cache dir without passing it; failures back off (15 min · 2^n, max 24 h) and saves fall back to the legacy per-request path.
  • gen is in the filename and URL (/api/media/<id>?g=<gen>): a URL never serves different bytes. A replaced gen is kept 30 min so in-flight Range playback survives.
  • Compression lane (rev OPT_REV = 2): niced x265 8-bit -preset medium -crf 28, hvc1 tag, anime-style tuning (X265_TUNING), -fpsmax 30, audio copied — kept only if ≥15% smaller, and the copy's vcodec becomes hevc. Sample-first: videos ≥60 s get two 10-s windows encoded first and compared with the source's own packet bytes over the same timestamps; the full encode only runs if the prediction clears the 15% bar (a skip costs ~20 s of video; a full encode ~2× realtime on the homelab). Why: re-encoding YouTube's already-compressed H.264 usually needs MORE bits for LOWER quality (generation loss) — on prod 0 of 5 videos shrank at CRF 28, one grew 25% (VMAF 93.6 at 126% of source; capping to 60–70% drops VMAF to ~84–87). Only videos where YouTube over-spent bits win (a live worship video: −34%). AnimeOut-style sizes come from pristine Blu-ray sources, which YouTube never gives us. Bumping OPT_REV re-queues every cached copy once at boot.
  • HEVC gating: an HEVC copy is served only to clients that send ?hevc=1 on /api/streams and /api/download (app.js hevcCapable() = canPlayType hvc1, minus a per-device ytpNoHevc flag set when an HEVC copy fails to play). Everyone else gets the pre-cache behaviour (YouTube proxy / legacy H.264 save). Verified: iPhone XR, Galaxy S10, Windows Chrome play HEVC; AV1 fails on the XR (no decoder), so it isn't used. The validation gate requires hvc1 (Safari refuses hev1) and 8-bit 4:2:0.
  • Budget: MEDIA_CACHE_MAX_BYTES (10 GiB) LRU by last_access (anything played in the last 10 min is protected) + MEDIA_MIN_FREE_BYTES (5 GiB) disk guard → skip, stream.
  • Broken: POST /api/media/:id/redownload (now-playing ⚠ Broken button). When a client falls back from a cached copy (/api/streams?nocache=1) the server also re-validates its copy and refetches it if it fails — never trusts one device's error.
  • Ops: curl https://worship.hesed.sbs/api/media/stats; files under /app/data/media in the container.

Lyrics · chapters · bookmarks (server/notes.js, frontend/lyrics-core.js)

  • Shared per video (every user sees the same copy): video_notes holds the live lyrics / chapters doc per (video_id, kind); every save also lands in video_note_revs as a full snapshot (history + undo). A daily JSON dump of all live notes goes to <DB dir>/backups/notes-YYYY-MM-DD.json (30 kept).
  • Who writes: a user linked to an online profile (body profile must exist — same trust level as the profile API), a script with an API token (Authorization: Bearer ytp_…, only a SHA-256 is stored), or the admin (signed httpOnly cookie). Saves are optimistic: client sends baseRev, gets 409 + current if someone saved in between (UI asks "Keep mine / Load theirs").
  • Personal, not shared: bookmarks (data.bookmarks[videoId]) and the per-song sync offset (data.lyricOffsets[videoId]) — both ride the profile sync. Line shows at t + doc.offset + personal offset (positive = later).
  • Editor text format = LRC superset: [1:23.45] line, # Section, ! band cue, @ Key G, 70 BPM tags, multi-stamp LRC lines and [offset:±ms] accepted.
  • Captions: GET /api/notes/:id/captions (preview) and token/admin-only POST /api/notes/:id/lyrics/auto pick human subs first, else the auto track in the spoken language (*-orig), never a machine translation. Auto captions of music are mostly junk ("oh", "n to") — expect to hand-fix or paste lyrics.
  • Admin: /admin (frontend/admin.html, never cached by the SW) — tokens, edit feed with view/restore, caption injection, JSON export, API examples. Disabled unless ADMIN_PASSWORD is set (Dokploy Environment tab; compose passes ${ADMIN_PASSWORD:-}). Login is rate-limited per IP.
  • Service mode → 🎵 Lyrics (data.settings.serviceLyrics, serviceLyricsAutoscroll): lyrics-only view; the sung line is centred, highlighted and sized to the largest font that fits ONE row. Sizing is measured with a hidden real .sl-line in TWO passes — Bricolage Grotesque has an optical-size axis, so glyphs get relatively wider at small sizes and a single 100px measurement over-fits on phones. Re-fit (in place, never rebuilding rows — that would move the scroll position) on resize, document.fonts load, and +900 ms. Lyrics-only and the 📺 video panel are mutually exclusive.
  • Free batch transcription: scripts/lyrics/auto_lyrics.py (faster-whisper large-v3-turbo int8 on CPU, no API key/credits; venv at ~/.local/share/lyrics-asr/.venv). --missing uses GET /api/admin/media (admin or token). Measured on the 16-core laptop: ~0.3–0.65× real time; openai-whisper medium was ~2.5×. vad_filter must stay False — VAD calls sung music non-speech and returns an empty transcript. Karaoke/minus-one tracks have no vocals (reported "instrumental?"); their lyrics were read from the on-screen text (frame sampling + OCR), not audio. ElevenLabs Scribe also works but spends the TTS credit balance (free tier = 10k/month).
  • Offline: the last-seen notes per video are cached in localStorage (ytpNotesCache, 200 videos), so saved songs keep their lyrics offline.
  • Toast cap bug (fixed with this feature): toast() used while (children.length > 3) with a deferred remove — the 4th toast spun forever and Chrome killed the tab ("Target crashed" in Playwright, no JS error). Never cap with a loop whose exit depends on a deferred removal.

Playlist transitions + waveform (Transition / Wave in app.js)

  • data.settings.transition = off | gapless | crossfade (crossfadeSec, default 6); the ↦/⇥/⤨ button cycles it with a 6-s toast. Applies only to playlist/queue playback with a next song, not with loop-one or an A-B loop.
  • A hidden bridge <audio> preloads the next song's audio ~25 s early (offline copy, else /api/streams). Crossfade starts it crossfadeSec early and ramps both volumes; gapless starts it when the song ends. Then the queue advances normally with Player._handoff set: _startPlayback() seeks the new track to the bridge's position, plays, and releases the bridge on playing (failsafe 5 s / 20 s). afterLoad skips resume/A-marker seeks during a handoff; onTrackEnded ignores ended while one runs.
  • iOS ignores element.volume, so crossfade degrades to gapless there.
  • End-of-song race (fixed): the song's last timeupdate arrives with the element already paused (it ended), and ended can beat the fade timer. Both used to look like "user paused" → the crossfade was cancelled and the next song restarted from 0. tick() and onTrackEnded (Transition.onEnded()) now finish the handoff when a fade is running at the end.
  • Waveform: GET /api/media/:id/peaks = 400 RMS buckets (0..100, ^0.7 so quiet verses stay visible) from ffmpeg over the server-cached copy, memoised per file path (gen). Audio-only mode draws it on #waveCanvas BEHIND the range input (made transparent, still the control), + hover time tooltip and "⚡ Jump to the loudest part" (loudest ~5 s window). Not cached on the server → plain seek bar. Envelopes are kept in localStorage (ytpPeaks, 80 songs).
  • Tested with real audio: a local server whose media_cache holds 20-s clips (upsertMedia rows + <id>.1.m4a/.mp4 files) — Playwright's Chromium plays AAC; launch with --autoplay-policy=no-user-gesture-required.

Uploads + web lyrics

  • Uploads (server/uploads.js, UPLOAD_DIR, table uploads): an admin posts a file to /api/admin/uploads (multipart, ≤ 4 GB, admin cookie or API token). ffprobe runs ONCE and gives everything: title/artist/album tags, duration, whether there is a REAL video stream (a cover picture also appears as a video stream — check disposition.attached_pic), the cover itself (extracted to <id>.art.jpg; an uploaded image wins) and embedded lyrics tags (LYRICS / lyrics-eng / UNSYNCEDLYRICS / ©lyr) which are parsed with parseLrc and saved as that song's shared lyrics (synced when in LRC form). Ids are upl_<12 hex> and are accepted anywhere a video id is: /api/streams (audio → audioUrl + art, video → one "Original" quality), /api/download, peaks/gif/clip (audioSourcePath/videoSourcePath), notes. /api/search puts the library first and still answers when yt-dlp fails. The app: 📁 Library view, isUploadId/isMediaId helpers, audio uploads force audio mode so the cover art shows where the video would be.
  • Web lyrics: POST /api/notes/:id/lyrics/web asks LRCLIB (free, key-less, often SYNCED) using a cleaned title/artist + duration (cleanTitle/cleanArtist/pickLrclib, ± 6 s). Profile users, tokens and admins may call it; the lyrics worker tries it BEFORE transcribing. scripts/lyrics/web_lyrics.py --missing --agy adds an agy web-search fallback (plain text → untimed lines, to be timed with Tap-sync). Gotcha fixed here: a /g regex reused for .test() skips every other call (lastIndex) — cleanTitle keeps a stateless copy.

Watch party, sharing, playback extras (all in app.js unless noted)

  • Lyrics instead of the video (Notes.stageLyrics, #stageLyrics inside .player-stage): a per-device view for watch parties and sing-alongs — data.settings.stageLyrics / stageLyricsAutoscroll. It reuses the service-mode machinery (fitLyricLines, LyricsCore.activeIndex, the same .sl-line classes) and is driven from the normal playback tick. The <video> is only hidden (visibility), never unloaded, so playback, audio and party sync carry on untouched — that is why it is safe for a guest to switch views mid-party. Toggle from the watch-party panel or the bar inside the stage (hover on desktop, always visible on touch).
  • Watch party (server/party.js, Party): /ws/party (Bun has ONE websocket handler — sockets are tagged ws.data.hub = 'party' and routed in server.js). Host drives; guests extrapolate the host's state from the server-stamped ts (clock offset from hello.now), seek when > 1.5 s off, mirror play/pause. Guest actions within the 1.5 s "quiet" window after a sync are ignored; later ones → a cmd (if the host allows control) or "off sync" (Re-sync button). playNext/playPrev → Party.intercept, and guests never auto-advance or crossfade. Chat is relayed (≤ 500 chars, 400 ms flood guard, last 60 kept). Voice = WebRTC mesh, the smaller pid offers, STUN only (no TURN — strict NATs won't connect). Host resumes after a reload with the secret from hello (party survives 5 min without its host).
  • Timestamp sharing (Share): /?v=<id>&t=<sec> deep links (handled at boot via playVideoAt), YouTube youtu.be/<id>?t=, GIFs from GET /api/media/:id/gif (palettegen, ≤ 6 s, server-cached videos only) and soundbites from GET /api/media/:id/clip?fmt=mp3|m4r (m4r ≤ 40 s, written to a temp file so it's a normal faststart MP4 — iPhones reject fragmented ones).
  • External players (External): hands out /api/media/<id> (the single-file server copy) via Android intent: (VLC / MX / chooser), iOS vlc-x-callback, desktop vlc://, or an .m3u (this video or the queue's cached songs).
  • Notes: bookmarks with kind:'note' hold multi-line text (≤ 2000) and carry vt/vc (video title/channel); the 📝 Notes view lists them all, exports Markdown. Transcript search = 🔎 tab over the lyrics or the YouTube captions (/api/notes/:id/captions, cached per video).
  • PiP mirrors the PiP window's play/pause onto the whole player (dual mode's sound is on <audio>). EQ: 5 BiquadFilters; media elements are wired in only once a non-flat curve is picked (irreversible), disabled on iOS (Web Audio suspends on lock → would kill background audio). Sleep timer: fade over sleepFade s, then pause / "close" (window.close, falls back) / black curtain; "end of this song" also blocks Transition. Gestures: double-tap thirds ±10 s / play, pinch 1–3× (CSS transform), vertical swipes for volume / brightness (CSS filter) only in fullscreen or body.landscape-fs (inline the page keeps touch-action: pan-y so it still scrolls).
  • Local media tests: seed media_cache rows + <id>.1.m4a/.mp4 files; the Playwright channel: 'chromium' build plays H.264/AAC; --use-fake-device-for-media-stream gives WebRTC a fake mic.

Presenter view, stats, lyrics worker

  • Presenter (Presenter in app.js, /?present=<code>): a lyrics-only projector/TV screen. Pairs through the phone-remote relay like a phone (listen-only); the host's state carries v.id, cur, rate, paused and off (the host's personal lyric offset), and the presenter extrapolates the position between the ~1/s updates. Host modal → "Open presenter window here" opens a popup to drag onto a second display; /api/remote/qr/<code>?kind=present is its QR. Keys: F / double-click fullscreen, B blank screen. Forced dark tokens so it stays readable if the device is in light theme.
  • fitLyricLines(list) is the shared one-row font fitter for service-mode lyrics and the presenter — re-run it on font load, resize and +900 ms (first fit on a fresh page happens before Bricolage loads).
  • Stats (frontend/stats-core.js, pure + tested; StatsTrack + renderStats): data.stats.days[YYYY-MM-DD] = {s, p, songs:{id:plays}} (local days, 400 kept) synced with the profile. Seconds = media-time deltas while playing (seeks/pauses excluded, divided by playback rate); a play = 30 s heard in one load; commits every 60 s / on play / on page hide to avoid profile-push spam. Streak = consecutive days ≥ 5 min (today may still be pending).
  • lyrics-worker (compose service, scripts/lyrics/Dockerfile): runs auto_lyrics.py --missing --watch 300 forever — one song per cycle, nice 10, cpus: 2, mem_limit: 3g, audio fetched from http://ytplayer:3000 over the private lyrics network (never the WAN). Auth = env LYRICS_WORKER_TOKEN (≥ 24 chars) accepted by notes.js as api:lyrics-worker without a DB row; unset → the worker idles. /data/state.json remembers instrumentals (never retried) and failures (backoff 15 min·2^n). Model (~1.6 GB) downloads to the lyrics-models volume on first start.

Phone remote (server/remote.js, Remote in app.js)

  • Desktop tab playing to a TV = host; phone = remote. Both are browser tabs of this app, so the server relays over /ws/remote (Bun server.upgrade in the Bun.serve fetch wrapper, before Hono). Mockup: docs/mockups/mock-06-remote-control.html (it assumed a LAN host + mDNS; the web app can't do that, so it's a server relay + a 6-digit code instead).
  • Host secret lives in the desktop's localStorage (ytpRemoteHost); room id = hash(secret). Pairing (POST /api/remote/pair) is a one-time 6-digit code (10 min) → phone gets token = HMAC(secret, remoteId) (ytpRemotePair). Nothing about phones is stored server-side: tokens are re-checked against the connected host's secret, so server restarts / desktop reloads keep phones paired, and "Unpair all" (new secret) revokes every phone at once.
  • The host pushes state (≤1/s, deduped) and queue ({items, idx} of the live queue/queueIndex); commands are whitelisted server-side (REMOTE_COMMANDS) and run by runCommand() on the host with the same functions the UI uses.
  • REMOTE_SAME_NETWORK=1 = pairing requires the phone and desktop to share a public IP (first X-Forwarded-For hop). Off by default — verify that the VPS→homelab Traefik chain forwards the real client IP before turning it on.
  • QR: GET /api/remote/qr/:code (server-side SVG via qrcode) encodes <origin>/?pair=<code>; the app consumes and strips ?pair= at boot.

Local dev

npm run setup            # download bin/yt-dlp (once)
cd server && bun install
ln -s ../frontend public # once — the server serves ONLY ./public (Docker copies frontend/ there)
bun --hot server.js      # http://localhost:3000
node --test frontend/    # unit tests (run from repo root)
npx playwright test      # e2e (see Testing below)

Local DB file: server/data/ytplayer.db (gitignored). BUILD_TAG is computed from ./public contents.

Production deployment (web PWA)

  • URL: https://worship.hesed.sbs (Traefik label in docker-compose.yml)
  • Runs on the homelab Dokploy remote node; control plane is Dokploy on the VPS (193.160.119.172, API key in ~/development/.secrets/dokploy-api.env).
  • Compose ID: wprYCM8T51f7JtSHb983p (project ytplayer, env production).
  • Pushing to git does NOT deploy. Trigger explicitly (build ≈ 5–6 min):
    ssh -i ~/.ssh/tmp_vps/dokploy_session root@193.160.119.172 \
      "curl -s -X POST -H 'x-api-key: $KEY' -H 'Content-Type: application/json' \
       -d '{\"composeId\":\"wprYCM8T51f7JtSHb983p\"}' http://localhost:3000/api/compose.deploy"
    # poll composeStatus via /api/compose.one?composeId=... until done|error
    
  • Confirm the deploy landed: curl https://worship.hesed.sbs/api/version — the buildTag (content hash of every file under ./public) must change.
  • Homelab node is NOT always reachable on LAN; SSH via the VPS hop: ssh root@193.160.119.172 → ssh root@10.8.0.2 (WireGuard). Container name: ytplayer-main-1dihzn-ytplayer-1. DB: libsql file /app/data/ytplayer.db (query with docker exec <c> bun -e '...' using @libsql/client).

Update-flow architecture (fragile — read before touching)

  • GET /sw.js is served by the server with the real BUILD_TAG injected by regex over the fallback expression in frontend/sw.js. Never switch back to an exact-string replace: when the fallback literal was bumped (v1.0.3→v1.0.4) the exact match silently failed, the SW version froze, and no client ever received another update while /api/version kept announcing one — the "Update available keeps showing" bug.
  • BUILD_TAG hashes every file under ./public recursively. Don't reduce it to a file subset; a change to an unlisted shell file would stop busting caches.
  • The banner has ONE rule (maybeShowUpdateBanner in app.js): show it only when the build this page runs differs from /api/version. The server stamps the running build into index.html (<meta name="ytp-build">, at request time like sw.js's BUILD_TAG); the SW caches that index.html with the rest of the shell, so the meta always describes the code in the tab. The poll, a waiting worker and the SW_UPDATE_AVAILABLE broadcast are only prompts to re-check. This loop was "fixed" three times by chasing individual triggers — don't add a trigger that calls showUpdateBanner() directly.
  • Background download + instant swap (added 2026-09-22). A new worker precaches the whole shell during install, so by the time the user presses "Refresh UI" the build is usually already on disk. applyUpdate asks the waiting worker CACHE_STATUS over a MessageChannel; when it answers ready: true (it re-checks every SHELL url, since a cache can be evicted) the download is skipped entirely and the swap is immediate. Two rules keep this safe: the probe is opt-in (askStatus) — never implicit, because a probe awaiting a reply hangs forever if the caller injects a setTimeout that never fires (the existing tests do exactly that) — and any doubt (no reply, timeout, thrown error, ready: false) falls back to the all-or-nothing download below, which is still what guarantees correctness. maybeShowUpdateBanner also calls prefetchUpdate() → registration.update() so the download starts the moment a new build is seen rather than when the user clicks.
  • Refresh UI (frontend/sw-update.js): refreshShellInPlace() re-downloads every file the shell caches hold — cache-busted (?__ytpfresh=) so even an OLD worker's cache-first handler can't answer from its cache, 3 tries per file, all-or-nothing — and writes them into every versioned shell cache; then it activates a waiting worker (SKIP_WAITING) if any and reloads once. A failed download shows an error toast and re-offers later; it never reloads into the old shell. checkUpdateOutcome() verifies after the reload (sessionStorage, max 3 attempts) instead of looping.
  • Why it kept looping on prod: the VPS→homelab link is slow and drops requests. The SW install (cache.addAll, all-or-nothing) failed, Refresh UI reloaded into the old cached shell, and the partial ytplayer-<tag> cache left by the failed install later made activate broadcast "update available" to pages that were already current. sw.js now precaches with cache: 'reload', retries each file, and deletes its partial cache on failure.
  • Reproduce with the throttling/stalling proxy approach: WebKit (Playwright on the Windows side) or Chrome over CDP against a scratch copy of the server, stalling every Nth shell request after "deploying" v2. A fast local link never shows the bug.

Cache layout & offline thumbnails

  • Three cache families, and the split matters on activate: the versioned shell cache ytplayer-<BUILD_TAG> is evicted on every deploy, while the utility caches ytplayer-thumbs and ytplayer-fonts are listed in UTILITY_CACHES and deliberately survive it. Adding a new utility cache means adding it there too, or it gets wiped on the next deploy.
  • Thumbnails are cache-first, not stale-while-revalidate — a given thumbnail URL is immutable, so revalidating just burns a round trip per image per launch.
  • The opaque-response trap (this silently emptied the thumb cache for months). An <img> to another origin is a no-cors request, so fetch(request) in the SW resolves to an opaque response with status === 0 — not 200. The old guard was if (r.status === 200) cache.put(...), which rejected every single thumbnail, so ytplayer-thumbs was permanently empty and offline showed broken images (measured on prod: 0 entries after browsing pages full of visible thumbs). Fix in thumbnail(): re-issue the request in cors mode — ytimg/ggpht all send Access-Control-Allow-Origin: * — and cache that readable response; an opaque one is accepted only as a last resort. Never reintroduce a bare status === 200 check on a cross-origin subresource.
  • Thumbnail hosts live in THUMB_HOSTS. An unlisted host doesn't error — it just bypasses the cache and breaks offline, so add mirrors/avatar hosts there.
  • The thumb cache is capped at THUMB_CACHE_MAX (800, oldest-first via the insertion-ordered cache.keys()). Keep a cap: CacheStorage and the OPFS offline audio share one origin quota, and opaque entries are padded to ~7 MB each for quota accounting, so an unbounded thumb cache can evict saved audio.
  • App side (app.js): warmThumb() pulls artwork through the SW when a video is saved offline, and warmOfflineThumbs() runs a bounded launch backfill (THUMB_WARM_MAX = 400, 4 at a time) over cached ids + playlist videos so libraries saved before this fix repair themselves. Thumbnails only cache when something requests them — a device needs one online launch to get offline art.
  • staleWhileRevalidate() (fonts) must not return cached || networkFetch bare: the fetch resolves to null offline and respondWith(null) throws. It ends with || Response.error().

Section rail + foldable cards (phones)

SectionRail in app.js draws a floating right-hand rail on phones with one icon per visible card (player · now playing · lyrics · related · list). It follows the scroll, a tap jumps to that card, and a tap on the card you are already on folds/unfolds it (data.settings.foldedCards, per device). Folding hides a card's body but never its header, so it stays a landmark you can scroll to and reopen; the Related card's own +/− button routes through SectionRail.setFolded so the two can never disagree.

Two things about this page that break naive implementations:

  • The document does not scroll. .player-pane is the scroll container in the browser, but the installed PWA in portrait scrolls .body instead (.player-pane becomes overflow-y: visible there). scrollerOf() walks up to whichever ancestor actually scrolls, and viewBox() measures against it.
  • Scroll events do not bubble, so a window scroll listener never fires for an element scroller — the listener is on document with capture: true. Cards also appear outside render() (Related arrives with the video's related list), so onScroll re-runs build(), which is a no-op unless the visible set changed; without that the rail could highlight nothing at all.

First paint vs. the network (launch)

boot() used to await the profile pull and the shared-playlist inbox before the first render(), so on a slow link the sidebar stayed empty for as long as the network took. Everything the device knows is already in localStorage, so it now paints immediately and reconciles afterwards:

  • ?list= / ?profile= share links still run before the first paint — they replace the synced slice, so painting first would flash the old playlists and swap them out. They are skipped entirely when the parameter is absent.
  • syncOnLaunch() then runs the network pass with a spinner (#syncSpinner, beside the Playlists header, setSyncing() is counted so the last finisher clears it) and re-renders only if playlistFingerprint() changed — a needless render would drop the sidebar's scroll position.

Data model quirks

  • Client state persists in localStorage key _ytpdata and syncs (debounced 400 ms) to POST /api/user/sync, keyed by a browser fingerprint.
  • A-B loop markers are per-song-per-playlist: stored on the playlist's own copy of the video (entry.ab = {a, b}) when playback source is that playlist; data.abMarkers[videoId] is only the fallback for non-playlist playback.
  • Online profiles (profiles table, /api/profile/*): named cross-device sync where the lowercase profile NAME is the only credential (passkey-style, by design). Client stores data.profile = {name, syncedAt}; sync is last-write-wins — push debounced on every persist(), pull on app launch when the server's updated_at is newer than the local syncedAt.
  • Profile share links: ?profile=<name> is consumed by adoptProfileFromUrl() at boot, before any rendering. It strips the param via replaceState (so a reload can't re-fire it) and confirms first when the device already has playlists/history or another profile — adopting replaces the synced slice. The name comes off the URL untrusted, hence escapeHtml() on it.
  • Empty-home playlist grid: renderHomePlaylists() swaps the branding hero in #playerPlaceholder for the user's playlists (hero is the no-playlists fallback). It is driven from the tail of renderSidebar() — not from render() — so every playlist mutation refreshes both in one place.

Testing

  • Unit: node --test frontend/*.test.js (sw, sw-update, async-guard, video-edit, lyrics-core, stats-core, sha256). Server: cd server && bun run test — runs each file in its own process (db.js is a singleton, so two test files in one bun test run share one temp DB; bare bun test also drags in the frontend node tests via the public symlink). media-cache needs a 60 s hook timeout for its ffmpeg fixtures. Local e2e needs a CURRENT yt-dlp — a 2-month-old one 403s on every download. The directory form node --test frontend/ fails on Node 22 with Cannot find module .../frontend — it resolves the dir as a module, not a test glob. That's the harness, not the tests.
  • E2E: npx playwright test — WebKit iPhone-12 profile against a static serve of frontend/ (needs npx playwright install webkit). A spurious update banner will make the settings-panel specs fail with #modal intercepts pointer events — that failure mode is a real app bug, not test flake. On the WSL laptop the WebKit install fails host validation (missing libgtk-4, libgstreamer*, …, needs sudo npx playwright install-deps); chromium is already downloaded, so ad-hoc rendering/offline checks can drive it directly instead.
  • Service-worker behaviour (thumb caching, offline) is only provable in a real browser: serve frontend/ statically, let the SW take control (needs a second reload), then context.setOffline(true) and assert img.naturalWidth > 0 plus the ytplayer-thumbs entry count. Asserting against the browser's own HTTP cache proves nothing — check the Cache API entry count.
  • Test records on prod use Probe */Recon * names; clean via the container DB, children (video_history) first.

Harness

Skills live in .agents/skills/ (symlinked into .claude/skills/):

  • deploy-prod — the Dokploy deploy + buildTag verification flow (manual-only; use for any "deploy"/"release" request).
  • mobile-app-ui-design — UI/UX design work on the PWA screens.
  • lyrics-lookup — fill in songs that have NO lyrics (LRCLIB → agy web search → local faster-whisper).
  • lyrics-regenerate — back up, then replace wrong/mistimed lyrics from LRCLIB (scripts/lyrics/lrclib_regen.py).
  • lyrics-agy — last resort when LRCLIB has nothing: agy (flat-rate) searches the web and often returns LRC-timed lyrics (scripts/lyrics/agy_lyrics.py). Also the --from-file path for pasting lyrics you already have. agy is NOT deterministic — the script asks up to --tries times and keeps the best (timed > untimed > longer). Its answer arrives on stderr, long answers are truncated behind a keep_id, and a quota-failed instance returns error prose inside a STATUS: ok envelope that will parse as lyrics if you let it.

Matching a song on LRCLIB (learned the hard way)

/api/streams gives the YouTube channel, not the artist, so a title+artist query finds nothing for a lyric-video channel ("Integrity Worship", "Christian Lyrics") — lrclib_lookup falls back to a title-only search and then ranks candidates whose artist verifies 50 points above those that don't. Two guards sit on the result, and both exist because they caught a real wrong song:

  • artist_ok() ignores GENERIC words. Channel "Christian Lyrics" once vouched for a track featuring Christian Burns (Nicky Romero, "Still the Same Man"). Never verify an artist on a word that says nothing about who recorded the song.
  • title_run() compares whole words, never substrings — "Still" is a substring of "(You Can Still) Rock in America", and a one-word title is rejected outright. It gates the LENGTH rule (same title, within 3 s of the same length ⇒ accept even when the artist can't be checked). An unverified match is printed as UNSURE and skipped; --loose overrides that and is almost always the wrong answer.

Admin lyric editor (frontend/admin.html)

/admin?v=<id> (or the Edit/Lyrics buttons) opens a song with its audio from /api/streams, the waveform from /api/media/:id/peaks, and one row per line: Set stamps the playhead, ±0.2 s nudges, Tap mode stamps the highlighted line on Space and walks down, plus shift-all, line/section/cue kinds, a ✎ Text LRC view and a ⤓ LRCLIB pull. Saves are optimistic (baseRev, 409 → keep mine / load theirs). Rows are built once per load and then only their classes and values change — re-rendering on every tick would steal focus from the field being typed in and reset the scroll position.

Commit rules

One changeset = one commit, single-line imperative message, no AI attribution of any kind (global rule). git push origin main pushes to both remotes.