From 9c1ec4ac5195e7d53f71db5295fbe4f0247c884e Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 20 Sep 2026 00:07:12 +0800 Subject: [PATCH] Add presenter view, stats, lyrics worker, watch party with voice chat, timestamp sharing, soundbites, notes, transcript search, PiP, EQ, sleep fade, gestures and external players; fix the crossfade end-of-song race --- CLAUDE.md | 71 +- docker-compose.yml | 33 + frontend/app.js | 1822 +++++++++++++++++++++++++++++++-- frontend/index.html | 59 +- frontend/stats-core.js | 152 +++ frontend/stats-core.test.js | 67 ++ frontend/styles.css | 231 +++++ frontend/sw.js | 1 + scripts/lyrics/.dockerignore | 1 + scripts/lyrics/Dockerfile | 15 + scripts/lyrics/auto_lyrics.py | 147 ++- server/notes.js | 4 +- server/notes.test.js | 8 + server/package.json | 2 +- server/party.js | 170 +++ server/party.test.js | 117 +++ server/server.js | 123 ++- 17 files changed, 2912 insertions(+), 111 deletions(-) create mode 100644 frontend/stats-core.js create mode 100644 frontend/stats-core.test.js create mode 100644 scripts/lyrics/.dockerignore create mode 100644 scripts/lyrics/Dockerfile create mode 100644 server/party.js create mode 100644 server/party.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 8e39841..aefe57f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,11 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone `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 @@ -137,6 +142,70 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone (upsertMedia rows + `.1.m4a/.mp4` files) — Playwright's Chromium plays AAC; launch with `--autoplay-policy=no-user-gesture-required`. +## Watch party, sharing, playback extras (all in app.js unless noted) +- **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=&t=` deep links (handled at + boot via `playVideoAt`), YouTube `youtu.be/?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/` (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 `