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:
71
CLAUDE.md
71
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 + `<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
|
||||
|
||||
Reference in New Issue
Block a user