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

This commit is contained in:
Jonathan Sykes
2026-09-20 00:07:12 +08:00
parent ca07e1fdf1
commit 9c1ec4ac51
17 changed files with 2912 additions and 111 deletions

View File

@@ -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 + `<id>.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=<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`
@@ -281,7 +350,7 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f
`render()` — so every playlist mutation refreshes both in one place.
## Testing
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit).
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit, lyrics-core, stats-core).
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