Compare commits

...

20 Commits

Author SHA1 Message Date
Jonathan Sykes
613f8fb902 Cover Now Playing returns after browsing in both layouts 2026-10-03 18:29:48 +08:00
Jonathan Sykes
cdc367a972 Restore the playing playlist when returning to Now Playing 2026-10-03 18:28:58 +08:00
Jonathan Sykes
f2c9459a54 Unify Glass Stage navigation and page controls with Now Playing 2026-10-03 18:19:56 +08:00
Jonathan Sykes
8ea92c1aac Report phone playback metadata once per browser session 2026-10-03 17:56:01 +08:00
Jonathan Sykes
856ddda6a6 Backfill device song metadata and queue guarded server caching 2026-10-03 17:55:49 +08:00
Jonathan Sykes
9e960cf70f iPhone background audio in every playback mode; Classic playlist card actions; export sheet bar
- Dual mode (separate video + audio, used by adaptive streams and the iPhone EQ
  copies): going to the background now makes the audio the master, so progress,
  Now Playing and auto-advance keep working while iOS has the video suspended
  (the paused video used to be the only source of "ended", so playback stopped
  after the current song). The picture is re-synced on return.
- A track that starts while the app is in the background (auto-advance, the
  lock-screen Next button) starts as audio — iOS will not start a <video> there —
  and gets its picture back at the same position when the app returns.
- The handoff also runs on pagehide and when iOS pauses the master video while
  the page is hidden, for when visibilitychange arrives too late.
- Classic: playlist cards place their three actions as a 2x2 block (note + add
  on top, remove below) instead of the note button wrapping to the bottom-left.
- Save to this device: hide the full progress bar once the file is ready; it
  read as a squashed, broken button above "Save to Photos / Files".
2026-10-03 17:46:23 +08:00
Jonathan Sykes
ca836e0cd2 Align saved video actions and confirm storage removal 2026-10-03 16:10:46 +08:00
Jonathan Sykes
cf9b6f4133 Clarify download states and make the empty page useful 2026-10-03 16:05:42 +08:00
Jonathan Sykes
0374e19396 Add lazy piano transcription and optional leased server worker 2026-10-03 15:47:53 +08:00
Jonathan Sykes
d4354d220e Preserve and display bilingual and phonetic lyric cues 2026-10-03 15:20:52 +08:00
Jonathan Sykes
c8ab741d72 Upgrade to ten-band EQ with migrated curves and headphone widening 2026-10-03 15:16:09 +08:00
Jonathan Sykes
0b3e82a768 Add voice search and large-control Car mode 2026-10-03 15:10:30 +08:00
Jonathan Sykes
172e8231f0 Import worship setlists with confirmed song matches and keys 2026-10-03 15:09:51 +08:00
Jonathan Sykes
de89508bf4 Add centre-panned vocal reduction to live and saved audio 2026-10-03 14:59:44 +08:00
Jonathan Sykes
7d84fdb3dc Add transparent OBS lyrics output through presenter pairing 2026-10-03 14:58:20 +08:00
Jonathan Sykes
05e05ebace Add learnable MIDI pedal controls with hot-plug support 2026-10-03 14:56:06 +08:00
Jonathan Sykes
b72d2e36a6 Add floating synced lyrics with picture-in-picture fallback 2026-10-03 14:54:23 +08:00
Jonathan Sykes
d7721045fb Add host-approved party suggestions and live upvotes 2026-10-03 14:52:10 +08:00
Jonathan Sykes
75dbc468cc Add download controls and device storage breakdown 2026-10-03 14:47:57 +08:00
Jonathan Sykes
09ae6a2a85 Organize settings into searchable sections with responsive navigation 2026-10-03 14:42:39 +08:00
86 changed files with 3355 additions and 181 deletions

View File

@@ -13,6 +13,8 @@ services:
- /mnt/data/ytplayer:/app/bulk - /mnt/data/ytplayer:/app/bulk
environment: environment:
PORT: "3000" PORT: "3000"
PIANO_WORKER_ENABLED: "${PIANO_WORKER_ENABLED:-0}"
PIANO_WORKER_TOKEN: "${PIANO_WORKER_TOKEN:-}"
MEDIA_DIR: "/app/bulk/media" MEDIA_DIR: "/app/bulk/media"
MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data" MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data"
MEDIA_CACHE_MAX_BYTES: "268435456000" # 250 GiB of the 458 GiB drive MEDIA_CACHE_MAX_BYTES: "268435456000" # 250 GiB of the 458 GiB drive
@@ -102,9 +104,27 @@ services:
networks: networks:
- lyrics - lyrics
# Optional high-fidelity transcription. Never starts without --profile piano.
piano-worker:
profiles: ["piano"]
build: ./scripts/piano
restart: unless-stopped
depends_on: [ytplayer]
environment:
YTP_BASE: "http://ytplayer:3000"
YTP_TOKEN: "${PIANO_WORKER_TOKEN:-}"
PIANO_THREADS: "2"
volumes:
- piano-models:/models
cpus: "2"
mem_limit: 6g
networks: [lyrics]
volumes: volumes:
ytplayer-data: ytplayer-data:
driver: local driver: local
piano-models:
driver: local
lyrics-models: lyrics-models:
driver: local driver: local
lyrics-state: lyrics-state:

View File

@@ -0,0 +1,11 @@
# Backfill songs played from device storage
A web player's first actual playback of a YouTube video in a session reports its known title, artist/channel, duration and thumbnail to `POST /api/media/:id/meta`. Local OPFS playback therefore no longer depends on `/api/streams` being called. Offline plays are held in memory until the browser returns online. Uploads and edited copies are excluded. Playback never waits for the report.
The server accepts only 11-character YouTube IDs, bounded strings, numeric duration and approved HTTPS YouTube artwork (local catalog artwork maps to the canonical thumbnail). Bodies are capped at 8 KB. Reports are limited to 20 per IP per minute and 100 globally, with a maximum of 32 outstanding backfill fetches. A response contains `{ok, known, cache}`; `known` describes whether a media row existed before the report, and `cache` is ready, queued, downloading, validating, unavailable or busy. Metadata can be registered even when the cache volume is offline or the queue is busy.
Device hints fill missing media fields with an atomic SQLite merge; existing titles, artists, durations, art and richer metadata are retained. Catalog ingestion also fills gaps and queues the existing bounded thumbnail collector. Admin's song list includes metadata-only entries, so its lyric tools have a title and artist before downloading completes. Inclusion in that list does not imply cached media is ready; `cacheStatus` reports the actual state.
Missing copies use the normal low-priority automatic `ensureCached` job, including its deduplication, single fetch lane, yt-dlp path, validation, duration/backoff limits, LRU byte budget, disk guard and USB volume marker. No parallel downloader or cache directory is introduced. Existing copies and in-progress jobs are skipped. Download failure keeps song metadata; the usual cache retry policy applies on later sessions. Offline-volume and busy responses do not force a fetch or bypass safety limits.
Verification: server validation/merge/route tests, admin metadata-only listing test, frontend once-per-session/offline reporter tests, all frontend unit tests, app syntax and server build. On a real phone, play a previously unknown OPFS song, inspect `/api/admin/media`, then verify a normal media-cache job and lyrics lookup. Repeat the play to confirm no second report; also test with the server's cache volume unavailable.

7
docs/equalizer.md Normal file
View File

@@ -0,0 +1,7 @@
# Equalizer and stereo width
`frontend/eq-core.js` is the single band/preset definition for both live EQ and EqRender: 31, 62, 125, 250, 500, 1000, 2000, 4000, 8000 and 16000 Hz. Existing five-band curves migrate by logarithmic frequency interpolation; edge gains extend to the new endpoints. Migration preserves user curves instead of replacing them with a new preset.
The headphone widener is off by default. It delays the right channel by up to 15 ms (Haas effect); it can colour speaker playback and mono mixes. Live EQ and saved-song renders use the same widener graph, vocal matrix and bands. Render signatures include all three, so changed settings invalidate old processed copies. iPhone streams still require the existing Live sound processing opt-in; saved copies use OfflineAudioContext. A new rendered copy is used on a subsequent playback.
Review with actual headphones and an iPhone: centre image at 0%, mono compatibility at higher values, rendered/live EQ correspondence, and lock-screen playback of saved songs. Model-based separation is outside this feature; see vocal-stems.md.

View File

@@ -0,0 +1,18 @@
# Glass Stage control consistency
Glass navigation and browsing controls now use Now Playing's existing vocabulary: 20 px SVGs with rounded 1.9 strokes, translucent tile surfaces, 18 px button corners, muted foregrounds, coral active/destructive states, and visible focus rings. Navigation badges use the same palette with readable 11 px counts.
The stylesheet is entirely scoped to `html[data-layout="glass-stage"]`. Existing hidden navigation SVGs are reused. A small Glass-only module draws legacy button glyphs without changing IDs, listeners or existing accessible labels; switching away restores the original glyphs and removes its temporary accessibility labels. It observes page updates only while Glass is selected. The existing Now Playing design remains the reference.
Coverage: bottom tabs and badges, sidebar rows, search/queue/history/playlist row actions, saved-file actions, download controls, Settings navigation/buttons, notes tabs and mini-player buttons. Glass row actions reserve space so long titles remain readable. Section shortcuts sit lower-left rather than covering right-hand file actions. Downloads' bottom-tab label stays fully visible.
Validation:
- 115 frontend unit tests; app/module syntax; server no-bundle build.
- Browser coverage for Classic and Glass at 390 and 1440 px across seven page states; keyboard button commands, focus, theme restoration, mini-player controls, settings reachability and file-action hit testing.
- Fourteen Classic before/after screenshots compare against the same fixture with the new assets disabled. Control geometry and computed styles match exactly. PNG comparison allows at most 16 isolated browser rasterization pixels in the entire screenshot (roughly 0.005% at phone size); no layout/theme difference is tolerated.
- Batched visual review and a single confirmation of the label/shortcut corrections. The apparent clipped Settings row is within its normal scroll container; a test reaches the final row.
Captures are local files under `/tmp/glass-polish-before-*` and `/tmp/glass-polish-after-*`, plus `/tmp/glass-polish-navigation-notes-mini.png` and `/tmp/glass-polish-settings-end.png`. Before/after fixtures suppress animations and automatic saving so playback/network timing cannot change the comparison. The installed bottom navigation is exposed in one isolated fixture because ordinary Chromium is not an installed PWA.
Reviewer: check a real installed iPhone PWA's bottom bar, mini-player clearance and theme switching, plus keyboard/hover behavior on desktop. No playback implementation, Classic stylesheet, credentials or deployment was changed.

12
docs/lyric-languages.md Normal file
View File

@@ -0,0 +1,12 @@
# Translations and pronunciation
A cue can contain a primary lyric and translation separated by ` / `:
```
[1:23.45] 主你的恩典 / Your grace is enough
!pinyin: zhǔ nǐ de ēndiǎn
```
The translation and phonetic text share the primary cue timestamp. They are smaller and muted beneath it in stage lyrics, Service mode, Presenter and OBS. The notes API preserves optional `secondary` and `phonetic` fields. Existing documents with only text, timing and kind remain unchanged.
Use `| ` continuation lines (or Shift+Enter in the editor) for tight breaks within a cue. A continuation can also carry `Primary / Translation`. Multiple timestamps copy translations and pronunciation to every occurrence. A slash without surrounding spaces, such as `and/or`, remains literal. Ordinary `! Band cue` lines retain their existing meaning.

View File

@@ -0,0 +1,36 @@
# Now Playing list return regression
The shared browsing list retains the last page independently of the live playback
queue. Search home hides `#listPane`; the mini-player previously only removed
`home-over` and scrolled. It did not reset that hidden flag or replace the previous
page with the playing playlist. At 390 px the original code fails in both Classic
and Glass Stage with `#listPane` still hidden after tapping the mini-player.
The separate `#upnext` card remained intact in this reproduction.
Returning now rebuilds the source playlist and Up next. If its source playlist
was removed (or playback came from search/channel/history), the list displays the
live queue, without replacing it with the unrelated editable saved queue.
Mini-player, close, Player jump, user-picked track/deep-link reveal and browser
history returns share the restoration. Auto-advance still skips the reveal hook.
History entries retain browsing views, and Settings section entries remain owned
by Settings. Repeated restoration does not add duplicate player history entries.
Run `npx playwright test --config playwright.now-playing.config.js`.
The 20 cases cover both themes at 390/1440 px, return controls, Back/Forward,
missing source playlists, stale Up next DOM, repeated returns and an exhausted
queue. Synthetic tracks avoid dependence on YouTube decoding/network; the
track-reveal case exercises the shared post-load hook, rather than real media
loading. Headless Chromium exposes the mini-player via test CSS because it is
not an installed phone PWA. The Player jump is tested only at phone width,
where that navigation exists.
Validation: 115 frontend unit tests, app syntax check, server build and 20 browser
cases pass. Replaying the 390 px mini-player tests with the original app script
fails in both themes at the hidden list assertion; the patched script passes.
No server implementation or tests changed.
On a real iPhone, confirm the same browse/return sequence in Safari and the
installed PWA, plus Back/Forward and an actual shared video deep link. Confirm
playback keeps its position and the page scrolls to the player without shifting
the document viewport. Desktop checks cover both themes; no playback/background,
playlist action layout or device-export implementation was changed.

View File

@@ -0,0 +1,28 @@
# Piano transcription
Commit 258aad8 added ideas, not runtime code. The implementation is now opt-in under Settings → Piano transcription.
## What runs where
- **Browser:** `frontend/piano-engine.mjs` imports pinned Spotify Basic Pitch 1.0.1 and TensorFlow.js 4.22.0 only after “Transcribe in browser”. It resamples to mono 22,050 Hz in OfflineAudioContext, prefers WebGPU, and falls back to WebGL/CPU. Dependencies load from esm.sh; model/weights load from jsDelivr. Neither the engine nor model is in the service-worker install shell. No audio is uploaded by this path. Processing still needs a network on first use. File/device/server-cached audio is limited to 100 MB and 15 minutes. Basic Pitch is instrument-agnostic, not piano stem separation; mixtures may produce inaccurate notes.
- **Device cache:** IndexedDB `ytp-piano/notes`, per video id, stores the engine label and compact JSON sequence. Export produces `<id>.notes.json`. Each note is `{pitch,start,end,velocity,hand}`. Timing is in seconds; velocity is 1–127. Missing hands use a middle-C split (pitch < 60 = left), which is a visual aid, not reliable fingering. Settings shows an 88-key synced visualizer and a manual practice queue of 8-second A–B loops.
- **Server:** optional `server/piano.js` queues authenticated jobs in SQLite; `scripts/piano/worker.py` downloads server media on the private compose network and runs ByteDance's `piano_transcription_inference` on CPU. This does not run in the web server. Model loading happens at the first claimed job. The worker is off by default and the compose `piano` profile is inactive by default. The checkpoint is about 165 MB; runtime memory needs substantially more (container limit 6 GB).
## Enable the optional worker
Configure a dedicated random `PIANO_WORKER_TOKEN` of at least 24 characters and `PIANO_WORKER_ENABLED=1` for the app and worker. Enable the compose `piano` profile and build/start the worker during a separately approved deployment. It shares the existing private `lyrics` network, keeps its checkpoint in `piano-models`, and is limited to two CPUs. Nothing is pushed or deployed by these changes.
Use an admin API token in the Settings server-request field (kept only in the mounted UI, not persisted), or call the contract directly. Save the song on the server first. “Load server result” retrieves the finished sequence and stores it on the device. Worker jobs survive server restarts, have heartbeat leases and three attempts; expired workers cannot publish. Queue capacity is ten. Replacing a server media generation marks its old sequence stale.
## API contract
- `GET /api/media/:id/piano`: public `{ok,enabled,job}`; `job` is null or `{videoId,status,notes,error,updatedAt}`. Notes are available only after completion. Status: queued/running/ready/failed/stale.
- `POST /api/media/:id/piano`: existing admin session/API-token authentication. Returns an existing valid job or queues one (202). Worker off → 503; uncached source → 409; duration over 15 minutes → 422; queue full → 429.
- `POST /api/piano-worker/claim`: dedicated Bearer worker token; returns a job with lease and server-relative audioPath, or null. Claims are atomic.
- `POST /api/piano-worker/:id`: dedicated worker token plus JSON `{lease,action}`. Actions: heartbeat; complete with `notes`; fail with `error`. Invalid/expired leases → 409. Results are bounded and validated before saving.
The model path is based on [Spotify's browser API](https://github.com/spotify/basic-pitch-ts) and [ByteDance's inference package](https://github.com/qiuqiangkong/piano_transcription_inference). Their upstream licences apply. The browser's TFJS dependency override is pinned and tested with a synthetic WAV; review GPU performance and accuracy with real recordings.
## Review on devices
Verify no model request before Transcribe, GPU/CPU fallback, mobile memory limits, cache survival after reload, JSON export, keyboard alignment with seeking, and A–B practice loop timing. Run the optional worker against a short known piano recording before enabling it for users; heavy-model/container inference is not covered by ordinary unit tests. This version does not provide model-based stem separation, score notation, pedal transcription display, or MIDI wait-for-note practice.

View File

@@ -0,0 +1,17 @@
# Remaining features and screen refinement
All changes are local commits on main. Nothing was pushed or deployed.
| Feature | Status | Main files | Validation / reviewer checks |
| --- | --- | --- | --- |
| 9. Ten-band EQ | Done | frontend/eq-core.js, eq-core.test.js, app.js, styles.css; docs/equalizer.md | Five-band curves migrate; live playback and EqRender share bands/presets/widening. Unit and four Classic/Glass browser cases passed. Check headphones, mono compatibility, and saved versus live processing on a real iPhone. |
| 10. Bilingual lyrics | Done | frontend/lyrics-core.js, lyrics-display.js and tests; lower-third.js, app.js, server/notes.js and tests; docs/lyric-languages.md | Primary/secondary/phonetic text preserves timing and round trips. Service, stage, Presenter and OBS use shared display logic. Frontend and 18 server notes tests passed. Check font coverage, multiline fitting and remote output on real devices. |
| 11. Piano transcription | Done; optional heavy worker awaits real inference validation | frontend/piano-core.js, piano-engine.mjs, piano.js and tests; server/piano.js and tests; scripts/piano/; docs/piano-transcription.md | Browser Basic Pitch/TFJS loads only on request, chooses WebGPU where available with WebGL/CPU fallback, caches compact notes with hand heuristic, and supports visualizer/practice queue. Real repeated browser inference passed without growing tensor count. Four server and two Python worker tests passed. ByteDance worker scaffolding, leased API contract and optional compose profile are implemented and off by default; no heavy container was started. Check a known piano recording before enabling it. |
| Downloads polish | Done | frontend/downloads-page.js and tests, offline-pages.css, downloads.js, small app.js hooks; tests/offline-pages.spec.js | Drawn SVG empty state, no empty filter, storage information and Saved link. Active/queued/paused/failed groups reuse the existing manager and confirmed cancellation. Classic/Glass at 390 and 1440 px checked. Test real slow downloads, pause/resume, failure/retry and navigation while saving. |
| Saved polish | Done | frontend/saved-page.js and test, saved-page.css, renderSaved hooks; tests/offline-pages.spec.js | Storage summary and confirmed Clear all share one row. Export/delete have separate horizontal 44 px targets and SVG icons; long titles truncate. Individual deletions confirm, and failed storage operations retain records. Existing save-to-device sheet is called unchanged. Check iPhone export/share and real storage removal. |
Common asset changes are registered in frontend/index.html and the service-worker shell list. The protected background playback methods, Classic playlist actions and save-to-device sheet were not edited.
Final checks: every frontend/*.test.js, app.js syntax, server no-bundle build, and the offline-page browser suite. Feature-specific server and Python tests were run with their respective feature commits. The 21-case settings suite and real Basic Pitch inference also passed. Offline-page tests cover all four layout/width combinations, filtering, cancellation, delete failure and action geometry.
Impeccable context loading, polish and craft-floor references were followed. Batched screenshots were assessed; final screenshots wait for the incumbent card reveal animation to finish. The manual detector found one advisory: the restrained Downloads empty-state heading is 20 px, intentionally smaller than the browsing title; no global typography change was made. The pre-existing design sidecar was reported stale and left outside this refinement.

5
docs/vocal-stems.md Normal file
View File

@@ -0,0 +1,5 @@
# Vocal reduction and future stems
The Vocal control uses a stereo mid/side matrix, not source separation. The same `frontend/vocal-reducer.js` graph runs in live Web Audio and EqRender's OfflineAudioContext. At 100% it is identity; at 0% it retains the stereo side and removes the centre. Centred instruments are reduced too, and mono can disappear. Live processing on iPhone requires the existing opt-in; saved songs render in the background and keep lock-screen playback.
TODO: add optional Demucs source separation. Follow the lyrics-worker deployment pattern, with its own model volume, token and resource limits. Suggested contract: `POST /api/media/:id/stems` starts an idempotent job, `GET /api/media/:id/stems` returns queued/running/ready/failed plus vocal/instrumental URLs. The worker fetches server audio over the private network, runs Demucs, atomically publishes stems and retains model/version/source-hash provenance. Authenticate job creation, cap concurrency and file size, and never fetch arbitrary user URLs. Keep the worker and feature off by default. No stems API or model currently runs.

View File

@@ -296,7 +296,12 @@ const SaveSlots = (() => {
while (waiters.length && active < limit()) { active++; waiters.shift()(); } while (waiters.length && active < limit()) { active++; waiters.shift()(); }
} }
return { return {
acquire() { return new Promise((r) => { waiters.push(r); pump(); }); }, acquire(signal) { return new Promise((resolve, reject) => {
const abort = () => { const i = waiters.indexOf(ready); if (i >= 0) waiters.splice(i, 1); reject(new DOMException('Paused', 'AbortError')); };
const ready = () => { signal?.removeEventListener('abort', abort); resolve(); };
if (signal?.aborted) return abort();
signal?.addEventListener('abort', abort, { once: true }); waiters.push(ready); pump();
}); },
release() { active = Math.max(0, active - 1); pump(); }, release() { active = Math.max(0, active - 1); pump(); },
pump, pump,
}; };
@@ -307,6 +312,7 @@ const SaveSlots = (() => {
// fails for good. A save that only paused (partial kept on the device) stays // fails for good. A save that only paused (partial kept on the device) stays
// listed and is resumed when the app opens, comes back online or returns to // listed and is resumed when the app opens, comes back online or returns to
// the foreground — and every 2 minutes while the app is visible. // the foreground — and every 2 minutes while the app is visible.
const saveControllers = new Map();
const SaveQueue = (() => { const SaveQueue = (() => {
const KEY = 'ytpSaveQueue'; const KEY = 'ytpSaveQueue';
const MAX_AGE = 7 * 24 * 3600 * 1000; const MAX_AGE = 7 * 24 * 3600 * 1000;
@@ -323,19 +329,21 @@ const SaveQueue = (() => {
at: (q[v.id] && q[v.id].at) || Date.now() }; at: (q[v.id] && q[v.id].at) || Date.now() };
store(q); store(q);
} }
function state(id, status, error = '') { const q = load(); if (q[id]) { Object.assign(q[id], { status, error }); store(q); } }
function pause(id) { state(id, 'paused'); saveControllers.get(id)?.abort(); }
function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } } function remove(id) { const q = load(); if (q[id]) { delete q[id]; store(q); } }
const pending = () => Object.values(load()); const pending = () => Object.values(load());
const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id)); const paused = () => pending().filter((v) => !downloading.has(v.id) && !cachedIds.has(v.id));
const get = (id) => prog.get(id) || null; const get = (id) => prog.get(id) || null;
function label(p) { function label(p) {
if (!p) return '⏳ Saving for offline…'; if (!p) return 'Saving for offline…';
if (p.phase === 'waiting') return '⏳ Waiting for a free slot…'; if (p.phase === 'waiting') return 'Waiting for a free slot…';
const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1); const mb = (n) => (n / 1048576).toFixed(n >= 1048576 * 100 ? 0 : 1);
if (p.phase === 'preparing') return `⏳ Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`; if (p.phase === 'preparing') return `Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
if (p.phase === 'verifying') return '✓ Verifying…'; if (p.phase === 'verifying') return 'Verifying…';
if (p.total) return `⬇ ${mb(p.received)} / ${mb(p.total)} MB`; if (p.total) return `${mb(p.received)} / ${mb(p.total)} MB`;
return '⏳ Saving for offline…'; return 'Saving for offline…';
} }
function paint(id) { function paint(id) {
// Only the Downloads view's own rows: search/playlist cards also carry // Only the Downloads view's own rows: search/playlist cards also carry
@@ -361,7 +369,7 @@ const SaveQueue = (() => {
const jobs = []; const jobs = [];
for (const v of pending()) { for (const v of pending()) {
if (cachedIds.has(v.id)) { remove(v.id); continue; } if (cachedIds.has(v.id)) { remove(v.id); continue; }
if (downloading.has(v.id)) continue; if (downloading.has(v.id) || v.status === 'paused' || v.status === 'failed') continue;
if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; } if (Date.now() - v.at > MAX_AGE) { cancel(v.id); continue; }
jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once jobs.push(preload(v, { quiet: true })); // SaveSlots caps how many run at once
} }
@@ -370,6 +378,7 @@ const SaveQueue = (() => {
if (view.type === 'downloads') renderList(); if (view.type === 'downloads') renderList();
} }
function cancel(id) { function cancel(id) {
saveControllers.get(id)?.abort();
remove(id); remove(id);
prog.delete(id); prog.delete(id);
if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(id); if (window.OPFS && window.OPFS.discardPartial) window.OPFS.discardPartial(id);
@@ -380,7 +389,7 @@ const SaveQueue = (() => {
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeAll(); });
setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000); setInterval(() => { if (document.visibilityState === 'visible' && paused().length) resumeAll(); }, 120000);
} }
return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, start }; return { add, remove, pending, paused, progress, get, label, resumeAll, cancel, pause, state, start };
})(); })();
// Resumable save (docs/resumable-downloads-plan.md): ask the server to get // Resumable save (docs/resumable-downloads-plan.md): ask the server to get
@@ -389,7 +398,7 @@ const SaveQueue = (() => {
// single-request save (HEVC copy this device can't play, too long for the // single-request save (HEVC copy this device can't play, too long for the
// server cache, no worker/OPFS sync access), otherwise the save result — // server cache, no worker/OPFS sync access), otherwise the save result —
// { ok:false, paused:true } means the partial is kept and will resume. // { ok:false, paused:true } means the partial is kept and will resume.
async function resumableOpfsSave(videoId, qs, hevc, onProgress) { async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) {
if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null; if (typeof window.OPFS.downloadVideo !== 'function' || typeof Worker === 'undefined') return null;
const enc = encodeURIComponent(videoId); const enc = encodeURIComponent(videoId);
const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`; const prepUrl = `/api/download/${enc}/prepare${hevc ? '?hevc=1' : ''}`;
@@ -399,9 +408,10 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
for (let round = 0; round < 2; round++) { for (let round = 0; round < 2; round++) {
let prep = null; let prep = null;
for (;;) { for (;;) {
if (signal?.aborted) return { ok: false, paused: true };
let j; let j;
try { try {
const r = await fetch(prepUrl, { cache: 'no-store' }); const r = await fetch(prepUrl, { cache: 'no-store', signal });
j = await r.json(); j = await r.json();
} catch (err) { } catch (err) {
return { ok: false, paused: true, error: 'connection lost' }; return { ok: false, paused: true, error: 'connection lost' };
@@ -414,6 +424,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
await new Promise((r) => setTimeout(r, 3000)); await new Promise((r) => setTimeout(r, 3000));
} }
const w = await window.OPFS.downloadVideo(videoId, dlUrl, { const w = await window.OPFS.downloadVideo(videoId, dlUrl, {
signal,
resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' }, resumable: { etag: prep.etag, size: prep.size, sha256: prep.sha256 || null, ext: prep.ext || 'mp4' },
onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }), onProgress: (received, total, m) => report({ phase: m && m.verifying ? 'verifying' : 'downloading', received, total }),
}); });
@@ -426,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress) {
return { ok: false, error: 'the server copy kept changing — try again' }; return { ok: false, error: 'the server copy kept changing — try again' };
} }
async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) { async function opfsDownload(videoId, { mux = false, onProgress = null, signal } = {}) {
if (!window.OPFS || !window.OPFS.isSupported()) { if (!window.OPFS || !window.OPFS.isSupported()) {
return { ok: false, error: 'OPFS not supported in this browser' }; return { ok: false, error: 'OPFS not supported in this browser' };
} }
@@ -441,7 +452,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
const qs = params.toString(); const qs = params.toString();
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`; const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress); const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal);
if (resumed) return resumed; if (resumed) return resumed;
// Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes, // Preferred path: a dedicated Web Worker does the fetch AND the OPFS writes,
@@ -451,13 +462,14 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
// is kept so it can be reported if the fallback fails too. // is kept so it can be reported if the fallback fails too.
let workerError = null; let workerError = null;
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
const w = await window.OPFS.downloadVideo(videoId, url); const w = await window.OPFS.downloadVideo(videoId, url, { signal });
if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 }; if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 };
if (w.paused) return w;
workerError = w.error || null; workerError = w.error || null;
} }
try { try {
const res = await fetch(url); const res = await fetch(url, { signal });
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
return { ok: false, error: j.error || `HTTP ${res.status}` }; return { ok: false, error: j.error || `HTTP ${res.status}` };
@@ -468,7 +480,7 @@ async function opfsDownload(videoId, { mux = false, onProgress = null } = {}) {
await window.OPFS.writeFromResponse(videoId, ext, res); await window.OPFS.writeFromResponse(videoId, ext, res);
return { ok: true, cached: true }; return { ok: true, cached: true };
} catch (err) { } catch (err) {
return { ok: false, error: workerError || err.message }; return { ok: false, paused: signal?.aborted || false, error: workerError || err.message };
} }
} }
@@ -657,6 +669,7 @@ let playFullMode = false; // "play in full" session: ignore resum
// A-B markers, shuffle, loop and repeat — play the // A-B markers, shuffle, loop and repeat — play the
// list start-to-finish, then stop. // list start-to-finish, then stop.
let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? } let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? }
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// iPhone: object URL of the playing saved song's rendered EQ/levelled sound. // iPhone: object URL of the playing saved song's rendered EQ/levelled sound.
let _currentEqUrl = null; let _currentEqUrl = null;
async function useEqRender(id) { async function useEqRender(id) {
@@ -692,7 +705,7 @@ const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
}); });
// ---------- DOM ---------- // ---------- DOM ----------
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id) || window.LyricsWindow?.element(id);
const els = { const els = {
video: $('video'), video: $('video'),
audio: $('audio'), audio: $('audio'),
@@ -1796,18 +1809,21 @@ async function preload(video, { quiet = false, mux = false } = {}) {
if (current && current.meta && current.meta.id === id) updateNowPlayingActions(); if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
if (!quiet) toast(`Saving “${video.title}” for offline…`); if (!quiet) toast(`Saving “${video.title}” for offline…`);
SaveQueue.add(video); SaveQueue.add(video);
const controller = new AbortController(); saveControllers.set(id, controller);
SaveQueue.progress(id, { phase: 'waiting' }); SaveQueue.progress(id, { phase: 'waiting' });
let slot = false; let slot = false;
try { try {
await SaveSlots.acquire(); await SaveSlots.acquire(controller.signal);
slot = true; slot = true;
SaveQueue.progress(id, null); SaveQueue.progress(id, null);
const res = await API.cacheDownload(id, { mux, onProgress: (p) => SaveQueue.progress(id, p) }); const res = await API.cacheDownload(id, { mux, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
if (res && res.paused) { if (res && res.paused) {
// The partial stays on the device; SaveQueue picks it up again. // The partial stays on the device; SaveQueue picks it up again.
if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`); if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
} else { } else if (res?.ok) {
SaveQueue.remove(id); SaveQueue.remove(id);
} else {
SaveQueue.state(id, 'failed', res?.error || 'Save failed');
} }
if (res && res.ok && res.cached) { if (res && res.ok && res.cached) {
if (typeof EqRender !== 'undefined') EqRender.schedule(id); if (typeof EqRender !== 'undefined') EqRender.schedule(id);
@@ -1820,9 +1836,11 @@ async function preload(video, { quiet = false, mux = false } = {}) {
toast('⚠ ' + ((res && res.error) || 'Could not save video')); toast('⚠ ' + ((res && res.error) || 'Could not save video'));
} }
} catch (e) { } catch (e) {
SaveQueue.remove(id); if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed');
if (!quiet) toast('⚠ Saving not supported in this build.'); if (!quiet && e.name !== 'AbortError') toast('⚠ Saving not supported in this build.');
} finally { } finally {
saveControllers.delete(id);
if (controller.signal.aborted && !SaveQueue.pending().some(v => v.id === id)) await window.OPFS?.discardPartial?.(id);
if (slot) SaveSlots.release(); if (slot) SaveSlots.release();
SaveQueue.progress(id, null); SaveQueue.progress(id, null);
downloading.delete(id); downloading.delete(id);
@@ -2461,11 +2479,18 @@ const Player = {
const V = els.video, A = els.audio; const V = els.video, A = els.audio;
// An uploaded audio file has no picture track: play it like audio-only and // An uploaded audio file has no picture track: play it like audio-only and
// show its cover art where the video would be. // show its cover art where the video would be.
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio'); // iOS won't start a <video> while the app is in the background (screen
// locked, app switched). A track that starts there — auto-advance, the
// lock-screen Next button — plays as audio and gets its picture back when
// the app returns (restoreFromBackgroundAudio).
const bgStart = IS_IOS && document.visibilityState === 'hidden' && !data.settings.audioOnly;
this._bgAudioMode = bgStart;
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
// Reset // Reset
V.pause(); A.pause(); V.pause(); A.pause();
this.stopDrift(); this.stopDrift();
this._bgHandoff = false;
if (current.localUrl && current.eqAudioUrl && !audioOnly) { if (current.localUrl && current.eqAudioUrl && !audioOnly) {
// iPhone: the saved video's picture (muted) + its rendered EQ/levelled // iPhone: the saved video's picture (muted) + its rendered EQ/levelled
@@ -2569,6 +2594,13 @@ const Player = {
// up from the bridge's position and release it once we are really playing. // up from the bridge's position and release it once we are really playing.
_handoff: null, _handoff: null,
_startPlayback() { _startPlayback() {
if (this._resumeAt != null) {
const t = this._resumeAt, paused = this._resumePaused;
this._resumeAt = null; this._resumePaused = false;
try { if (t > 0.05) this.seek(t); } catch { /* not seekable yet */ }
if (!paused) this.play();
return;
}
const h = this._handoff; const h = this._handoff;
this._handoff = null; this._handoff = null;
if (!h) { this.play(); return; } if (!h) { this.play(); return; }
@@ -2718,8 +2750,18 @@ const Player = {
// 'dual' and 'audio' modes already play through <audio> and need no handoff. // 'dual' and 'audio' modes already play through <audio> and need no handoff.
handoffToBackgroundAudio() { handoffToBackgroundAudio() {
if (!this._wantsPlaying || this._bgHandoff) return; if (!this._wantsPlaying || this._bgHandoff) return;
if (this.mode === 'dual' && this.secondary === els.audio) {
// Dual mode: the sound already comes from <audio>, but the paused video
// was still the master — the clock, and the source of "ended". Make the
// audio the master so the progress, Now Playing and the next song keep
// going while the video is suspended.
this.master = els.audio;
this.secondary = null;
this._bgHandoff = 'dual';
return;
}
if (this.mode !== 'progressive') return; if (this.mode !== 'progressive') return;
const src = (current && (current.localUrl || current.audioUrl)) || ''; const src = (current && (current.eqAudioUrl || current.localUrl || current.audioUrl)) || '';
if (!src) return; // nothing to continue with — leave playback alone if (!src) return; // nothing to continue with — leave playback alone
const V = els.video, A = els.audio; const V = els.video, A = els.audio;
try { try {
@@ -2742,8 +2784,30 @@ const Player = {
// Undo handoffToBackgroundAudio, putting the video back in charge at the // Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away. // position the audio reached while the app was away.
restoreFromBackgroundAudio() { restoreFromBackgroundAudio() {
// A track that STARTED in the background is audio-only: re-attach its
// picture at the position the audio reached.
if (this._bgAudioMode && current && !data.settings.audioOnly) {
const t = (this.master && this.master.currentTime) || 0;
const wasPlaying = this._wantsPlaying;
this._bgAudioMode = false;
this._resumeAt = t;
this._resumePaused = !wasPlaying;
this.attach(current.localUrl ? null : chooseQuality());
return;
}
if (!this._bgHandoff) return; if (!this._bgHandoff) return;
const V = els.video, A = els.audio; const V = els.video, A = els.audio;
if (this._bgHandoff === 'dual') {
const t = A.currentTime || 0;
this._bgHandoff = false;
this.master = V;
this.secondary = A;
try {
V.currentTime = t;
if (this._wantsPlaying) { const p = V.play(); if (p && p.catch) p.catch(() => {}); }
} catch { /* the watchdog picks up whatever is left */ }
return;
}
const t = A.currentTime || 0; const t = A.currentTime || 0;
this._bgHandoff = false; this._bgHandoff = false;
this.master = V; this.master = V;
@@ -2794,6 +2858,7 @@ document.addEventListener('visibilitychange', () => {
Player.handoffToBackgroundAudio(); Player.handoffToBackgroundAudio();
} }
}); });
window.addEventListener('pagehide', () => Player.handoffToBackgroundAudio());
window.addEventListener('pageshow', () => { window.addEventListener('pageshow', () => {
Player.restoreFromBackgroundAudio(); Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded(); Player.resumeIfNeeded();
@@ -2850,6 +2915,11 @@ function wirePlayerEvents() {
function bind(el) { function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); }); el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('pause', () => { el.addEventListener('pause', () => {
// iOS paused the master video because the app went to the background
// (sometimes before visibilitychange arrives): hand the sound over now.
if (el === V && masterIs(el) && Player._wantsPlaying && document.visibilityState === 'hidden') {
Player.handoffToBackgroundAudio();
}
if (masterIs(el) && Player.secondary) { if (masterIs(el) && Player.secondary) {
Player.clearBufferGrace(); Player.clearBufferGrace();
// ONLY a deliberate pause stops the synced audio. iOS pauses a // ONLY a deliberate pause stops the synced audio. iOS pauses a
@@ -2882,6 +2952,7 @@ function wirePlayerEvents() {
}); });
el.addEventListener('playing', () => { el.addEventListener('playing', () => {
if (!masterIs(el)) return; if (!masterIs(el)) return;
if (WEB && current?.meta) window.ServerBackfill?.report(current.meta);
if (performance.getEntriesByName('ytp:tap').length) { if (performance.getEntriesByName('ytp:tap').length) {
perfMeasure('ytp:tap-to-play', 'ytp:tap'); perfMeasure('ytp:tap-to-play', 'ytp:tap');
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
@@ -3281,7 +3352,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const fits = []; const fits = [];
for (const el of rows) { for (const el of rows) {
// Size a grouped cue by its widest visual line, preserving tight breaks. // Size a grouped cue by its widest visual line, preserving tight breaks.
const parts = el.textContent.split('\n'); const parts = (el.querySelector('.lyric-primary') || el).textContent.split('\n');
widthAt.text = parts.reduce((widest, part) => { widthAt.text = parts.reduce((widest, part) => {
widthAt.text = part; const width = widthAt(100); widthAt.text = part; const width = widthAt(100);
return width > widest.width ? { text: part, width } : widest; return width > widest.width ? { text: part, width } : widest;
@@ -3466,7 +3537,7 @@ const Notes = (() => {
if (l.kind === 'cue') { if (l.kind === 'cue') {
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`; return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
} }
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`; return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${LyricsDisplay.html(l)}</span></div>`;
}).join(''); }).join('');
if (st.sync) renderSyncPointer(); if (st.sync) renderSyncPointer();
else tick(now(), true); else tick(now(), true);
@@ -3752,7 +3823,11 @@ const Notes = (() => {
try { try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, { const res = await fetch(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: profileName(), overwrite }), body: JSON.stringify((() => {
const m = current && current.meta && current.meta.id === id ? current.meta : {};
return { profile: profileName(), overwrite, title: m.title || undefined,
artist: m.artist || m.channel || m.uploader || undefined, duration: m.duration || undefined };
})()),
}); });
const j = await res.json().catch(() => null); const j = await res.json().catch(() => null);
if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; } if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || `HTTP ${res.status}`), { duration: 5000 }); setStatus(''); return; }
@@ -4143,7 +4218,7 @@ const Notes = (() => {
return; return;
} }
list.innerHTML = items.map((l) => list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join(''); `<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
paintFlags(); paintFlags();
loadFlags(); loadFlags();
applyFits(); applyFits();
@@ -4254,7 +4329,7 @@ const Notes = (() => {
return; return;
} }
list.innerHTML = items.map((l) => list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join(''); `<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 }); fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
tickStage(now(), true); tickStage(now(), true);
clearTimeout(stg.refit); clearTimeout(stg.refit);
@@ -4526,6 +4601,7 @@ const Notes = (() => {
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn }, stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
jumpChapter, jumpChapter,
openChapterPicker, openChapterPicker,
repeatSection: () => { const cs = chapters().filter(c => c.t <= now()).sort((a, b) => a.t - b.t); Player.seek(abA ?? cs.at(-1)?.t ?? 0); },
chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; }, chapterAt: (t) => { const c = chapters().slice().sort((a, b) => a.t - b.t); let k = -1; c.forEach((x, i) => { if (x.t <= t) k = i; }); return k < 0 ? null : { n: k + 1, of: c.length, title: c[k].title || '' }; },
}; };
})(); })();
@@ -4874,6 +4950,7 @@ const Remote = (() => {
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open <div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div> <code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button> <button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
<button class="np-btn" type="button" data-act="obs">Copy OBS browser-source URL</button>
</div> </div>
</div> </div>
<div class="rh-label">Connected phones & screens</div> <div class="rh-label">Connected phones & screens</div>
@@ -4909,6 +4986,7 @@ const Remote = (() => {
else if (act.dataset.act === 'stop') stopHosting(); else if (act.dataset.act === 'stop') stopHosting();
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' }); else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
else if (act.dataset.act === 'unpair') unpairAll(); else if (act.dataset.act === 'unpair') unpairAll();
else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); }
else if (act.dataset.act === 'present') { else if (act.dataset.act === 'present') {
// A second window on this computer (drag it to the projector display). // A second window on this computer (drag it to the projector display).
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720'); window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
@@ -5926,7 +6004,8 @@ const Presenter = (() => {
const noop = { boot() {}, active: () => false }; const noop = { boot() {}, active: () => false };
const view = $('presenterView'); const view = $('presenterView');
if (!WEB || !view || typeof WebSocket === 'undefined') return noop; if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
const KEY = 'ytpPresenterPair'; const overlay = LowerThird.active();
const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair';
const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } }; const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } };
const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`; const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`;
@@ -5983,7 +6062,7 @@ const Presenter = (() => {
let m; try { m = JSON.parse(e.data); } catch { return; } let m; try { m = JSON.parse(e.data); } catch { return; }
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); } if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
else if (m.type === 'state') onState(m.state); else if (m.type === 'state') onState(m.state);
else if (m.type === 'host-offline') setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
}; };
ws.onclose = (e) => { ws.onclose = (e) => {
if (st.ws !== ws) return; if (st.ws !== ws) return;
@@ -6026,6 +6105,7 @@ const Presenter = (() => {
} }
function render(v) { function render(v) {
if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; }
const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line'); const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
const title = $('pvTitle'); const title = $('pvTitle');
if (!sung.length) { if (!sung.length) {
@@ -6038,7 +6118,7 @@ const Presenter = (() => {
const pad = Math.round(list.clientHeight / 2); const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px'; list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px'; list.style.paddingBottom = pad + 'px';
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join(''); list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
refit(); refit();
st.idx = -2; st.idx = -2;
tick(true); tick(true);
@@ -6057,6 +6137,7 @@ const Presenter = (() => {
function tick(force = false) { function tick(force = false) {
if (view.classList.contains('hidden') || !st.all.length || !st.state) return; if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; }
const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0)); const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
if (idx === st.idx && !force) return; if (idx === st.idx && !force) return;
st.idx = idx; st.idx = idx;
@@ -6090,7 +6171,7 @@ const Presenter = (() => {
function boot() { function boot() {
let param = null; let param = null;
try { param = new URLSearchParams(location.search).get('present'); } catch { /* no URL */ } try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
if (param === null) return; if (param === null) return;
try { try {
const u = new URL(location.href); const u = new URL(location.href);
@@ -6099,7 +6180,8 @@ const Presenter = (() => {
} catch { /* keep */ } } catch { /* keep */ }
show(); show();
const code = String(param).replace(/\D/g, ''); const code = String(param).replace(/\D/g, '');
if (code.length === 6) pair(code); if (overlay && load()) connect();
else if (code.length === 6) pair(code);
else if (load()) connect(); else if (load()) connect();
else showPairForm(); else showPairForm();
setInterval(() => tick(), 200); setInterval(() => tick(), 200);
@@ -6516,8 +6598,10 @@ const EqRender = (() => {
const g = EQ.gains(); const g = EQ.gains();
const lufs = Level.enabled() ? Level.remembered(id) : null; const lufs = Level.enabled() ? Level.remembered(id) : null;
const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2; const levelDb = lufs == null ? 0 : Math.round(Level.gainFor(lufs) * 2) / 2;
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5) return null; const vocal = VocalReducer.amount(data.settings.vocalLevel ?? 100);
return { gains: g, levelDb, sig: g.join(',') + '|' + levelDb }; const width = EqCore.width(data.settings.stereoWidth);
if (g.every((v) => v === 0) && Math.abs(levelDb) < 0.5 && vocal === 100 && width === 0) return null;
return { gains: g, levelDb, vocal, width, sig: g.join(',') + '|' + levelDb + '|vocal:' + vocal + '|width:' + width };
} }
async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); } async function dir() { return (await navigator.storage.getDirectory()).getDirectoryHandle(DIR, { create: true }); }
async function drop(id) { async function drop(id) {
@@ -6585,7 +6669,9 @@ const EqRender = (() => {
const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20); const gain = off.createGain(); gain.gain.value = Math.pow(10, w.levelDb / 20);
const lim = off.createDynamicsCompressor(); const lim = off.createDynamicsCompressor();
lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25; lim.threshold.value = -1; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.003; lim.release.value = 0.25;
node.connect(gain); gain.connect(lim); lim.connect(off.destination); const vocal = VocalReducer.create(off, node, w.vocal);
const widener = EqCore.createWidener(off, vocal.output, w.width);
widener.output.connect(gain); gain.connect(lim); lim.connect(off.destination);
src.start(); src.start();
const out = await off.startRendering(); const out = await off.startRendering();
if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it if (wanted(id)?.sig !== w.sig) return; // settings changed meanwhile — the queue re-runs it
@@ -6661,26 +6747,14 @@ const PiP = (() => {
})(); })();
// ============================================================================ // ============================================================================
// Equalizer — 5-band graphic EQ (Web Audio) // Equalizer — 10-band graphic EQ (Web Audio)
// ============================================================================ // ============================================================================
// Media elements are routed through BiquadFilters only once a non-flat curve // Media elements are routed through BiquadFilters only once a non-flat curve
// is chosen (createMediaElementSource can't be undone; afterwards "Flat" // is chosen (createMediaElementSource can't be undone; afterwards "Flat"
// simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when // simply zeroes the gains). Disabled on iOS: Safari suspends Web Audio when
// the screen locks, which would silence background playback. // the screen locks, which would silence background playback.
const EQ_BANDS = [ const EQ_BANDS = EqCore.bands;
{ f: 60, type: 'lowshelf', label: '60' }, const EQ_PRESETS = EqCore.presets;
{ f: 250, type: 'peaking', label: '250' },
{ f: 1000, type: 'peaking', label: '1k' },
{ f: 4000, type: 'peaking', label: '4k' },
{ f: 12000, type: 'highshelf', label: '12k' },
];
const EQ_PRESETS = {
flat: { name: 'Flat', g: [0, 0, 0, 0, 0] },
bass: { name: 'Bass boost', g: [7, 4, 0, 0, 0] },
vocal: { name: 'Vocal', g: [-2, -1, 3, 4, 1] },
classical: { name: 'Classical', g: [4, 2, -1, 1, 3] },
treble: { name: 'Treble boost', g: [0, 0, 0, 3, 6] },
};
const EQ = (() => { const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext; const AC = window.AudioContext || window.webkitAudioContext;
@@ -6695,7 +6769,7 @@ const EQ = (() => {
const rendered = () => !!(current && current.eqAudioUrl); const rendered = () => !!(current && current.eqAudioUrl);
const gains = () => { const gains = () => {
const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g; const g = data.settings.eq && Array.isArray(data.settings.eq.g) ? data.settings.eq.g : EQ_PRESETS.flat.g;
return EQ_BANDS.map((_, i) => Math.max(-12, Math.min(12, Number(g[i]) || 0))); return EqCore.migrate(g);
}; };
const isFlat = () => gains().every((v) => v === 0); const isFlat = () => gains().every((v) => v === 0);
@@ -6717,7 +6791,9 @@ const EQ = (() => {
st.limiter = st.ctx.createDynamicsCompressor(); st.limiter = st.ctx.createDynamicsCompressor();
st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20; st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20;
st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25; st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25;
st.filters[st.filters.length - 1].connect(st.level); st.vocal = VocalReducer.create(st.ctx, st.filters[st.filters.length - 1]);
st.widener = EqCore.createWidener(st.ctx, st.vocal.output);
st.widener.output.connect(st.level);
st.level.connect(st.limiter); st.level.connect(st.limiter);
st.limiter.connect(st.ctx.destination); st.limiter.connect(st.ctx.destination);
return true; return true;
@@ -6736,12 +6812,14 @@ const EQ = (() => {
// Apply the stored curve; wires the graph up the first time it's non-flat. // Apply the stored curve; wires the graph up the first time it's non-flat.
function apply() { function apply() {
if (!available()) return; if (!available()) return;
if (!st.ctx && isFlat()) return; if (!st.ctx && isFlat() && VocalReducer.amount(data.settings.vocalLevel ?? 100) === 100 && EqCore.width(data.settings.stereoWidth) === 0) return;
if (!build()) return; if (!build()) return;
attach(els.video); attach(els.video);
attach(els.audio); attach(els.audio);
const g = rendered() ? EQ_BANDS.map(() => 0) : gains(); const g = rendered() ? EQ_BANDS.map(() => 0) : gains();
st.filters.forEach((f, i) => { f.gain.value = g[i]; }); st.filters.forEach((f, i) => { f.gain.value = g[i]; });
st.vocal.set(rendered() ? 100 : data.settings.vocalLevel ?? 100);
st.widener.set(rendered() ? 0 : data.settings.stereoWidth);
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {}); if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
renderButton(); renderButton();
} }
@@ -7048,6 +7126,7 @@ const SectionRail = (() => {
const el = elOf(sec); const el = elOf(sec);
if (!el) return; if (!el) return;
flashLabel(btn); flashLabel(btn);
if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList();
if (st.active === sec.id && sec.fold) { if (st.active === sec.id && sec.fold) {
// Already here: this is the second tap — fold or unfold the card. // Already here: this is the second tap — fold or unfold the card.
setFolded(sec.id, !isFolded(sec.id)); setFolded(sec.id, !isFolded(sec.id));
@@ -7326,6 +7405,7 @@ const Party = (() => {
const st = { const st = {
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0, ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
dj: { enabled: false, entries: [] }, djPending: false,
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '', allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false, minimized: false,
@@ -7369,12 +7449,17 @@ const Party = (() => {
st.offset = (m.now || Date.now()) - Date.now(); st.offset = (m.now || Date.now()) - Date.now();
st.members = m.members || []; st.members = m.members || [];
st.chat = m.chat || []; st.chat = m.chat || [];
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
st.allowControl = !!m.allowControl; st.allowControl = !!m.allowControl;
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
render(); render();
if (isHost()) pushState(true); if (isHost()) pushState(true);
else if (m.state) applyState(m.state); else if (m.state) applyState(m.state);
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); } if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
} else if (m.type === 'dj') {
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
} else if (m.type === 'dj-play' && isHost()) {
st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
} else if (m.type === 'state') { } else if (m.type === 'state') {
st.state = m.state; st.state = m.state;
if (st.role === 'guest') applyState(m.state); if (st.role === 'guest') applyState(m.state);
@@ -7664,6 +7749,8 @@ const Party = (() => {
<div class="pt-chat" id="ptChat"></div> <div class="pt-chat" id="ptChat"></div>
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form> <form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`; <button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
renderChat(); renderChat();
} }
function renderChat() { function renderChat() {
@@ -7745,6 +7832,7 @@ const Party = (() => {
} }
return { return {
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true), boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
state: () => ({ state: () => ({
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on, role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
@@ -7961,6 +8049,27 @@ function playQueue(index = 0) {
playFromList(data.queue.slice(), index, 'queue'); playFromList(data.queue.slice(), index, 'queue');
} }
// Browsing replaces the shared list pane independently of the playing queue.
// Rebuild it whenever the player is revealed, including when home-over was
// already false: merely removing that class leaves Search's hidden pane stale.
function restoreNowPlayingList({ remember = true } = {}) {
if (!current?.meta) return;
if (remember && !history.state?.ytpNowPlaying) {
history.replaceState({ ...history.state, ytpBrowseView: view, ytpHomeOver: els.playerPane.classList.contains('home-over') }, '', location.href);
history.pushState({ ...history.state, ytpNowPlaying: true, ytpSettingsSection: null }, '', location.href);
}
setHomeOverPlayer(false);
els.playerPane.classList.remove('empty');
els.placeholder.classList.add('hidden');
const playlistId = queueSource.startsWith('playlist:') ? queueSource.slice(9) : null;
view = playlistId && data.playlists.some(p => p.id === playlistId)
? { type: 'playlist', id: playlistId }
: { type: 'nowPlaying' };
_lastViewKey = [view.type, view.id, view.smartType].join('|');
render();
renderUpNext();
}
function renderUpNext() { function renderUpNext() {
updateServiceUpNext(); updateServiceUpNext();
Remote.changed(); Remote.changed();
@@ -7996,6 +8105,7 @@ function renderUpNext() {
} }
// Advance to the next track. Wraps to the start when "repeat list" is on. // Advance to the next track. Wraps to the start when "repeat list" is on.
function advanceQueue() { function advanceQueue() {
if (Party.nextDJ()) return true;
if (queueIndex >= 0 && queueIndex < queue.length - 1) { if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++; queueIndex++;
} else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) { } else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
@@ -8576,6 +8686,7 @@ function updateHomeGridScrollState() {
} }
function currentList() { function currentList() {
if (view.type === 'nowPlaying') return queue;
if (view.type === 'search') return searchResults; if (view.type === 'search') return searchResults;
if (view.type === 'history') return data.history; if (view.type === 'history') return data.history;
if (view.type === 'queue') return data.queue; if (view.type === 'queue') return data.queue;
@@ -8670,7 +8781,7 @@ function renderList() {
if (view.type === 'library') { renderLibrary(); return; } if (view.type === 'library') { renderLibrary(); return; }
// Filter bar — show for filterable views // Filter bar — show for filterable views
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart']; const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart', 'nowPlaying'];
const showFilter = filterableViews.includes(view.type); const showFilter = filterableViews.includes(view.type);
$('listFilterBar').classList.toggle('hidden', !showFilter); $('listFilterBar').classList.toggle('hidden', !showFilter);
$('batchBar').classList.toggle('hidden', !selectMode); $('batchBar').classList.toggle('hidden', !selectMode);
@@ -8694,7 +8805,9 @@ function renderList() {
Recommendations.inList(); Recommendations.inList();
} }
if (view.type === 'search') { if (view.type === 'nowPlaying') {
els.listTitle.textContent = 'Now playing';
} else if (view.type === 'search') {
els.listTitle.textContent = 'Search results'; els.listTitle.textContent = 'Search results';
} else if (view.type === 'history') { } else if (view.type === 'history') {
els.listTitle.textContent = 'History'; els.listTitle.textContent = 'History';
@@ -8928,6 +9041,8 @@ async function exportToDevice(video) {
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1]; const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
const name = DE.fileName(title, ext, id); const name = DE.fileName(title, ext, id);
pct(100); pct(100);
// A full progress bar above the buttons read as a squashed, broken button.
bar.hidden = true;
say(`${name} · ${fmtBytes(file.size)}`); say(`${name} · ${fmtBytes(file.size)}`);
actions.innerHTML = ''; actions.innerHTML = '';
if (DE.canShareFile(file)) { if (DE.canShareFile(file)) {
@@ -9143,7 +9258,9 @@ async function renderSaved() {
resetListHeaderExtras(); resetListHeaderExtras();
els.listActions.innerHTML = ''; els.listActions.innerHTML = '';
els.status.classList.add('hidden'); els.status.classList.add('hidden');
$('batchBar').classList.add('hidden');
const c = els.cards; const c = els.cards;
c.classList.remove('select-mode');
c.innerHTML = '<div class="status">Loading saved videos…</div>'; c.innerHTML = '<div class="status">Loading saved videos…</div>';
let res; let res;
@@ -9151,6 +9268,7 @@ async function renderSaved() {
if (view.type !== 'saved') return; // navigated away if (view.type !== 'saved') return; // navigated away
if (!res || !res.ok) { if (!res || !res.ok) {
$('listFilterBar').classList.add('hidden');
c.innerHTML = ''; c.innerHTML = '';
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-state'; empty.className = 'empty-state';
@@ -9166,6 +9284,7 @@ async function renderSaved() {
const total = res.total || 0; const total = res.total || 0;
if (!items.length) { if (!items.length) {
$('listFilterBar').classList.add('hidden');
c.innerHTML = ''; c.innerHTML = '';
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'empty-state'; empty.className = 'empty-state';
@@ -9180,12 +9299,18 @@ async function renderSaved() {
// Header actions: total + clear all. // Header actions: total + clear all.
const clearAll = document.createElement('button'); const clearAll = document.createElement('button');
clearAll.type = 'button';
clearAll.className = 'saved-clear';
clearAll.textContent = 'Clear all'; clearAll.textContent = 'Clear all';
clearAll.setAttribute('aria-label', 'Clear all saved videos');
clearAll.onclick = () => { clearAll.onclick = () => {
showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [ showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
{ label: 'Cancel', onClick: closeModal }, { label: 'Cancel', onClick: closeModal },
{ label: 'Clear all', danger: true, onClick: async () => { { label: 'Clear all', danger: true, onClick: async () => {
try { await API.cacheClear(); } catch {} try {
const result = await API.cacheClear();
if (result?.ok === false) throw new Error(result.error || 'Storage could not be cleared');
} catch { toast('Could not clear saved videos. Try again.'); closeModal(); return; }
cachedIds.clear(); cachedIds.clear();
// Custom cuts live only in the cache — clearing it destroys their media, // Custom cuts live only in the cache — clearing it destroys their media,
// so drop their registry entries and playlist references too. // so drop their registry entries and playlist references too.
@@ -9203,12 +9328,14 @@ async function renderSaved() {
} }, } },
]); ]);
}; };
els.listActions.appendChild(clearAll);
c.innerHTML = ''; c.innerHTML = '';
const summary = document.createElement('div'); const summary = document.createElement('div');
summary.className = 'saved-summary'; summary.className = 'saved-summary saved-storage-summary';
summary.innerHTML = `<span class="saved-total">${fmtBytes(total)}</span><span class="saved-sub">${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`; summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
summary.appendChild(clearAll);
$('listFilterBar').classList.remove('hidden');
c.appendChild(summary); c.appendChild(summary);
items.forEach((it) => { items.forEach((it) => {
@@ -9218,6 +9345,7 @@ async function renderSaved() {
const isCustom = (known && known.custom) || String(it.id).startsWith('edit_'); const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`; const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb }; const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
if (!SavedPage.matches(v, listFilter)) return;
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id; row.dataset.id = it.id;
@@ -9227,27 +9355,41 @@ async function renderSaved() {
<div class="card-info"> <div class="card-info">
<div class="card-title"></div> <div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div> <div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div> </div>`;
<button class="card-act" data-act="export" title="Save to gallery" aria-label="Save to gallery">⤓</button>
<button class="card-del" title="Delete saved file">✕</button>`;
row.querySelector('.card-title').textContent = v.title || it.id; row.querySelector('.card-title').textContent = v.title || it.id;
row.addEventListener('click', (e) => { row.addEventListener('click', (e) => {
if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; } if (e.target.closest('.saved-file-actions')) return;
if (e.target.closest('.card-del')) return;
playFromList([v], 0, 'saved'); playFromList([v], 0, 'saved');
}); });
row.querySelector('.card-del').addEventListener('click', async (e) => { SavedPage.actions(row, v, () => exportToDevice(v), () => {
e.stopPropagation(); showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; } ? 'This edited copy exists only on this device and will be removed permanently.'
try { await API.cacheDelete(it.id); } catch {} : 'Frees space on this device. You can save this video again later.'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Delete saved file', danger: true, onClick: async () => {
try {
const result = await API.cacheDelete(it.id);
if (result?.ok === false) throw new Error(result.error || 'Storage could not be updated');
} catch { toast('Could not delete this saved file. Try again.'); closeModal(); return; }
cachedIds.delete(it.id); cachedIds.delete(it.id);
if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions(); if (isCustom) {
markCardCacheState(it.id, 'none'); data.customVideos = (data.customVideos || []).filter(x => x.id !== it.id);
toast('Removed from cache'); data.playlists.forEach(pl => { pl.videos = pl.videos.filter(x => x.id !== it.id); });
renderSaved(); data.queue = (data.queue || []).filter(x => x.id !== it.id);
persist();
}
if (current?.meta?.id === it.id) updateNowPlayingActions();
markCardCacheState(it.id, 'none'); closeModal();
toast('Removed saved file'); renderSaved();
} },
]);
}); });
c.appendChild(row); c.appendChild(row);
}); });
if (!c.querySelector('.saved-card')) {
const message = document.createElement('p'); message.className = 'status';
message.textContent = 'No saved videos match your filter.'; c.appendChild(message);
}
markPlayingCard(); markPlayingCard();
} }
@@ -9261,94 +9403,29 @@ function renderDownloads() {
els.status.classList.add('hidden'); els.status.classList.add('hidden');
const c = els.cards; const c = els.cards;
c.innerHTML = ''; c.innerHTML = '';
$('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length);
$('batchBar').classList.add('hidden');
c.classList.remove('select-mode');
const active = [...downloadMeta.values()]; if (window.DownloadsPage && window.DownloadsPage.render) {
const paused = SaveQueue.paused(); window.DownloadsPage.render(c, {
if (!active.length && !paused.length) { downloadsApi: window.Downloads,
const empty = document.createElement('div'); filter: listFilter,
empty.className = 'empty-state'; isActive: () => view.type === 'downloads',
empty.innerHTML = ` showModal: typeof showModal === 'function' ? showModal : null,
<div class="empty-icon">⬇</div> closeModal: typeof closeModal === 'function' ? closeModal : null,
<h3 class="empty-title">No active downloads</h3> onNavigateSaved: () => { view = { type: 'saved' }; render(); },
<p class="empty-desc">Saves in progress show here with live status. Finished videos land in <strong>Saved</strong>.</p>`; thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null,
const cta = document.createElement('button'); refresh: renderList
cta.className = 'empty-cta'; });
cta.textContent = '💾 View saved videos';
cta.addEventListener('click', () => { view = { type: 'saved' }; render(); });
empty.appendChild(cta);
c.appendChild(empty);
return;
} }
const note = document.createElement('div'); const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : [];
note.className = 'saved-summary'; jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => {
const n = active.length + paused.length; if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) {
note.innerHTML = `<span class="saved-total">${n}</span><span class="saved-sub">download${n === 1 ? '' : 's'}${paused.length ? ` · ${paused.length} paused` : ' in progress'}</span>`;
c.appendChild(note);
const rowFor = (v, isPaused) => {
const row = document.createElement('div');
row.className = 'card downloading dl-row' + (isPaused ? ' dl-paused' : '');
row.dataset.id = v.id;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, v) || '')}" alt="" />
<div class="dl-progress"><div class="dl-bar"></div></div>
</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel"></div>
</div>`;
row.querySelector('.card-title').textContent = v.title || v.id;
return row;
};
active.forEach((v) => {
c.appendChild(rowFor(v, false));
// paint the live label/bar now (the worker keeps updating it)
SaveQueue.progress(v.id, SaveQueue.get(v.id)); SaveQueue.progress(v.id, SaveQueue.get(v.id));
});
paused.forEach((v) => {
const row = rowFor(v, true);
row.querySelector('.card-channel').textContent = '⏸ Paused — continues when the connection is back';
const acts = document.createElement('div');
acts.className = 'dl-actions';
const resume = document.createElement('button');
resume.type = 'button';
resume.className = 'dl-act';
resume.textContent = '▶ Resume';
resume.onclick = (e) => { e.stopPropagation(); preload(v, { quiet: true }); renderList(); };
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'dl-act danger';
cancel.textContent = 'Cancel';
cancel.onclick = (e) => {
e.stopPropagation();
showModal(`Cancel saving “${v.title || v.id}”?`, document.createTextNode('The part already downloaded will be deleted.'), [
{ label: 'Keep', onClick: closeModal },
{ label: 'Cancel save', danger: true, onClick: () => { SaveQueue.cancel(v.id); closeModal(); renderList(); } },
]);
};
acts.append(resume, cancel);
row.querySelector('.card-info').appendChild(acts);
c.appendChild(row);
});
// Fill in how far each paused save got (read from the partial on disk).
if (paused.length && window.OPFS && window.OPFS.listPartials) {
window.OPFS.listPartials().then((parts) => {
for (const p of parts) {
const row = c.querySelector(`.card.dl-paused[data-id="${CSS.escape(p.id)}"]`);
if (!row || !p.size) continue;
const bar = row.querySelector('.dl-bar');
bar.classList.add('determinate');
bar.style.width = Math.max(1, Math.min(100, (p.received / p.size) * 100)) + '%';
row.querySelector('.card-channel').textContent =
`⏸ Paused at ${(p.received / 1048576).toFixed(0)} / ${(p.size / 1048576).toFixed(0)} MB — continues when the connection is back`;
}
}).catch(() => {});
} }
});
} }
// ============================================================================ // ============================================================================
@@ -9674,6 +9751,21 @@ async function renderSettings() {
</div>`; </div>`;
c.appendChild(wrap); c.appendChild(wrap);
const definitions = [
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, '<path d="M4 5h14M4 10h14M4 15h8m5-2v8m-3-4h6"/>'],
['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6M12 8v8m-3-3 3 3 3-3"/>'],
['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6"/>'],
['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', '<circle cx="12" cy="8" r="4"/><path d="M4 21v-3a8 8 0 0 1 16 0v3"/>'],
['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v1"/>'],
];
const groups = [...wrap.querySelectorAll(':scope > .set-group')];
definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } }));
SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast });
// ---- Wire appearance / accessibility ---- // ---- Wire appearance / accessibility ----
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); }); $('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
@@ -10029,7 +10121,7 @@ function renderCard(v, index, list) {
card.addEventListener('click', (e) => { card.addEventListener('click', (e) => {
if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return; if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return;
if (selectMode) { toggleSelectCard(v.id); return; } if (selectMode) { toggleSelectCard(v.id); return; }
playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type); playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type === 'nowPlaying' ? queueSource : view.type);
}); });
card.querySelector('.card-menu').addEventListener('click', (e) => { card.querySelector('.card-menu').addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -10108,8 +10200,28 @@ function markPlayingCard() {
}); });
} }
// Only entries created by returning to the player participate here; leave
// unrelated history (including Settings section navigation) to its owner.
window.addEventListener('popstate', event => {
const state = event.state;
if (state?.ytpSettingsSection) return;
if (state?.ytpNowPlaying) {
restoreNowPlayingList({ remember: false });
if (isStackedPlayerLayout()) document.querySelector('.body')?.scrollTo({ top: 0 });
}
else if (state?.ytpBrowseView) {
view = state.ytpBrowseView;
setHomeOverPlayer(!!state.ytpHomeOver);
render();
}
});
let _lastViewKey = null; let _lastViewKey = null;
function render() { function render() {
const key = [view.type, view.id, view.smartType].join('|');
if (history.state?.ytpNowPlaying && _lastViewKey !== null && key !== _lastViewKey) {
history.pushState({ ...history.state, ytpNowPlaying: false, ytpBrowseView: view, ytpHomeOver: els.playerPane.classList.contains('home-over'), ytpSettingsSection: null }, '', location.href);
}
listFilter = ''; listFilter = '';
const fi = $('listFilterInput'); const fi = $('listFilterInput');
if (fi) fi.value = ''; if (fi) fi.value = '';
@@ -10778,6 +10890,7 @@ function applyPortraitPwaClass() {
// In portrait mode, .body is the single scroll container (player pane on top, // In portrait mode, .body is the single scroll container (player pane on top,
// list pane below). Scrolling it to 0 brings the player fully into view. // list pane below). Scrolling it to 0 brings the player fully into view.
function scrollPlayerIntoViewPortrait() { function scrollPlayerIntoViewPortrait() {
restoreNowPlayingList();
if (!isStackedPlayerLayout()) return; if (!isStackedPlayerLayout()) return;
const body = document.querySelector('.body'); const body = document.querySelector('.body');
if (body) { if (body) {
@@ -10901,6 +11014,7 @@ function wireUI() {
// ---- Empty-home playlist grid ---- // ---- Empty-home playlist grid ----
$('homePlNewBtn').addEventListener('click', () => newPlaylist(null)); $('homePlNewBtn').addEventListener('click', () => newPlaylist(null));
const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist);
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true }); $('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
window.addEventListener('resize', updateHomeGridScrollState); window.addEventListener('resize', updateHomeGridScrollState);
@@ -11416,14 +11530,16 @@ document.querySelectorAll('.chip').forEach((c) => {
$('updateIconBtn').addEventListener('click', updateFromIcon); $('updateIconBtn').addEventListener('click', updateFromIcon);
$('chBadge').addEventListener('click', (e) => { e.stopPropagation(); Notes.openChapterPicker(); }); $('chBadge').addEventListener('click', (e) => { e.stopPropagation(); Notes.openChapterPicker(); });
$('miniBar').addEventListener('click', () => { $('miniBar').addEventListener('click', () => {
setHomeOverPlayer(false);
hideMiniBar(); hideMiniBar();
// Scroll the player into view if needed. In portrait PWA, scroll only the // Scroll the player into view if needed. In portrait PWA, scroll only the
// .body pane (the designated scroll container) — scrollIntoView() also // .body pane (the designated scroll container) — scrollIntoView() also
// scrolls overflow:hidden ancestors up to <html> on iOS, leaving the // scrolls overflow:hidden ancestors up to <html> on iOS, leaving the
// document offset and fixed-element hit testing broken. // document offset and fixed-element hit testing broken.
if (isStackedPlayerLayout()) scrollPlayerIntoViewPortrait(); if (isStackedPlayerLayout()) scrollPlayerIntoViewPortrait();
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); else {
restoreNowPlayingList();
els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}); });
// Opens the set-list view for whatever is already playing. No playlist arg, // Opens the set-list view for whatever is already playing. No playlist arg,
// so enterServiceMode leaves the live queue and position untouched. // so enterServiceMode leaves the live queue and position untouched.
@@ -11432,7 +11548,7 @@ document.querySelectorAll('.chip').forEach((c) => {
enterServiceMode(); enterServiceMode();
}); });
// On the home screen the mini bar is the way back, so ✕ returns to the player. // On the home screen the mini bar is the way back, so ✕ returns to the player.
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); setHomeOverPlayer(false); hideMiniBar(); }); $('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); restoreNowPlayingList(); hideMiniBar(); });
// Drag-to-reorder: prevent default on cards container // Drag-to-reorder: prevent default on cards container
els.cards.addEventListener('dragover', (e) => { els.cards.addEventListener('dragover', (e) => {
@@ -12393,18 +12509,36 @@ async function boot() {
syncOnLaunch(); syncOnLaunch();
startPlaylistInboxWatch(); startPlaylistInboxWatch();
Presenter.boot(); Presenter.boot();
if (LowerThird.active()) return;
SectionRail.boot(); SectionRail.boot();
Share.bootFromUrl(); Share.bootFromUrl();
bootViewFromUrl(); bootViewFromUrl();
bootSearchFromUrl(); bootSearchFromUrl();
if (WEB) bootShareFromUrl(); if (WEB) bootShareFromUrl();
Party.boot(); Party.boot();
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
Remote.boot(); Remote.boot();
LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click());
Transition.renderButton(); Transition.renderButton();
PiP.render(); PiP.render();
LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast });
EQ.renderButton(); EQ.renderButton();
if (data.settings.eq?.g?.length === 5) { data.settings.eq.g = EqCore.migrate(data.settings.eq.g); persist(); }
EQ.apply(); EQ.apply();
EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } });
Piano.configure({ settings: () => data.settings, persist, state: () => ({ id: current?.meta?.id || '', time: Player.master?.currentTime || 0, duration: Player.master?.duration || current?.meta?.duration || 0, paused: Player.master?.paused !== false }), loop: (start, end) => { abA = start; abB = end; saveAbMarkers(); updateAbUI(); Player.seek(start); Player.play(); }, clearLoop: clearAb });
MidiPedals.configure({ settings: () => data.settings, persist, action: action => {
if (action === 'toggle') { Player.toggle(); updatePlayBtn(); }
else if (action === 'next') playNext();
else if (action === 'repeat') Notes.repeatSection?.();
else if (action === 'loop') { if (abA === null) setAbA(); else if (abB === null) setAbB(); else clearAb(); }
else if (action === 'panic') MidiPedals.fade({ volume: Player.soundEl.volume, setVolume: v => { Player.soundEl.volume = v; }, pause: () => { Player.pause(); updatePlayBtn(); }, restore: () => Player.applyVolume() });
} });
applyAppearance(); applyAppearance();
CarMode.configure({ input: els.searchInput, search: () => els.searchForm.requestSubmit(), toast, state: () => ({ title: current?.meta?.title || '', paused: Player.master?.paused !== false }), action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); else if (action === 'prev') playPrev(); else if (action === 'back') Player.seek(Math.max(0, (Player.master.currentTime || 0) - 15)); } });
updateProfileStatus(); updateProfileStatus();
updateLoopRepeatButtons(); updateLoopRepeatButtons();
syncMuteIcon(); syncMuteIcon();
@@ -12424,6 +12558,16 @@ async function boot() {
data.playlists.forEach(preloadPlaylist); data.playlists.forEach(preloadPlaylist);
preloadPinnedPlaylists(); preloadPinnedPlaylists();
updateBottomChrome(); updateBottomChrome();
if (window.Downloads) Downloads.configure({
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true }); } },
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
const root = await navigator.storage.getDirectory();
for (const name of ['videos', 'eq-renders']) { try { const dir = await root.getDirectoryHandle(name); for await (const [filename, handle] of dir.entries()) { if (handle.kind !== 'file' || /\.json$/.test(filename)) continue; const file = await handle.getFile(); result.push({ id: filename.replace(/\.[^.]+(?:\.part)?$/, ''), size: file.size, kind: name === 'eq-renders' ? 'eq' : /\.(mp3|m4a|aac|ogg|opus|wav)(?:\.part)?$/i.test(filename) ? 'audio' : 'video' }); } } catch {} }
return result;
},
});
// Continue saves that were interrupted (closed app, dropped connection). // Continue saves that were interrupted (closed app, dropped connection).
if (WEB) SaveQueue.start(); if (WEB) SaveQueue.start();
// iPhone: bring saved songs' EQ/levelled copies up to date (one at a time). // iPhone: bring saved songs' EQ/levelled copies up to date (one at a time).

7
frontend/car-mode.css Normal file
View File

@@ -0,0 +1,7 @@
.car-mode { position: fixed; inset: 0; z-index: 20000; background: #070809; color: #fff; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); overflow: auto; display: flex; flex-direction: column; gap: 20px; }
.car-mode h1 { font: 650 clamp(24px, 4vw, 42px)/1.25 system-ui; margin: auto 0; overflow-wrap: anywhere; text-align: center; }
.car-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; width: min(100%, 800px); margin: auto; }
.car-controls button { min-height: 110px; font: 650 clamp(22px, 3vw, 36px)/1.2 system-ui; background: #fff; color: #050607; border: 3px solid #fff; border-radius: 16px; }
.car-exit { align-self: flex-start; min-height: 52px; padding: 12px 20px; background: #070809; color: #fff; border: 2px solid #fff; border-radius: 8px; }
.car-mode button:focus-visible { outline: 4px solid #61caff; outline-offset: 4px; }
.voice-search { flex-shrink: 0; min-width: 44px; min-height: 44px; }

29
frontend/car-mode.js Normal file
View File

@@ -0,0 +1,29 @@
/* Voice search and a deliberately sparse, high-contrast transport. */
(function(root) {
'use strict';
function transcript(event) { let text = ''; for (let i = event.resultIndex || 0; i < (event.results?.length || 0); i++) if (event.results[i].isFinal) text += `${event.results[i][0]?.transcript || ''} `; return text.trim(); }
let adapter, overlay, timer, savedFocus, previous = [];
function close() { if (!overlay) return; clearInterval(timer); overlay.remove(); overlay = null; for (const [node, inert] of previous) node.inert = inert; previous = []; savedFocus?.focus?.(); }
function open() {
if (overlay) return;
const doc = root.document; savedFocus = doc.activeElement; previous = [...doc.body.children].map(node => [node, node.inert]); previous.forEach(([node]) => { node.inert = true; });
overlay = doc.createElement('section'); overlay.className = 'car-mode'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Car mode');
const exit = doc.createElement('button'); exit.type = 'button'; exit.className = 'car-exit'; exit.textContent = 'Exit Car mode'; exit.onclick = close;
const title = doc.createElement('h1'); const controls = doc.createElement('div'); controls.className = 'car-controls'; let toggle;
for (const [label, action] of [['Previous', 'prev'], ['Play / Pause', 'toggle'], ['15 s back', 'back'], ['Next', 'next']]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-label', label); b.onclick = () => adapter.action(action); if (action === 'toggle') toggle = b; controls.append(b); }
const update = () => { const state = adapter.state(); title.textContent = state.title || 'Choose a song before entering Car mode'; toggle.textContent = state.paused ? 'Play' : 'Pause'; }; update();
overlay.append(exit, title, controls); doc.body.append(overlay); timer = setInterval(update, 500); exit.focus();
overlay.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Tab') { const buttons = [...overlay.querySelectorAll('button')], first = buttons[0], last = buttons.at(-1); if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus(); } } e.stopPropagation(); };
}
function configure(options) {
adapter = options; const doc = root.document, Recognition = root.SpeechRecognition || root.webkitSpeechRecognition;
if (Recognition) {
const b = doc.createElement('button'); b.type = 'button'; b.className = 'icon-btn voice-search'; b.title = 'Voice search'; b.setAttribute('aria-label', 'Voice search'); b.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg>'; adapter.input.after(b);
let recognition, listening = false;
b.onclick = () => { if (listening) { recognition.abort(); return; } recognition = new Recognition(); recognition.lang = doc.documentElement.lang || root.navigator.language || 'en-US'; recognition.continuous = false; recognition.interimResults = false; recognition.onstart = () => { listening = true; b.setAttribute('aria-pressed', 'true'); b.title = 'Listening… tap to cancel'; }; recognition.onend = () => { listening = false; b.setAttribute('aria-pressed', 'false'); b.title = 'Voice search'; }; recognition.onerror = e => adapter.toast(`Voice search: ${e.error === 'not-allowed' ? 'microphone permission was denied' : e.error}`); recognition.onresult = e => { const query = transcript(e); if (query) { adapter.input.value = query; adapter.search(); } }; try { recognition.start(); } catch (error) { adapter.toast(error.message); } };
}
const car = doc.createElement('button'); car.type = 'button'; car.className = 'icon-btn'; car.title = 'Car mode'; car.setAttribute('aria-label', 'Car mode'); car.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="m5 8 2-5h10l2 5M3 8h18v10H3zM5 18v3m14-3v3M6 12h2m8 0h2"/></svg>'; doc.getElementById('searchForm').after(car); car.onclick = open;
root.SettingsSections.register({ id: 'voice-car', title: 'Voice & Car mode', cluster: 'Listening', summary: () => Recognition ? 'Voice search available · Car mode' : 'Car mode · voice unavailable', render(container) { const p = doc.createElement('p'); p.textContent = Recognition ? 'Use the microphone beside Search to speak a query. Recognition availability and processing depend on your browser.' : 'Voice search is unavailable in this browser.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Enter Car mode'; b.onclick = open; container.append(p, b); } });
}
const api = { transcript, configure, open, close }; if (typeof module !== 'undefined') module.exports = api; else root.CarMode = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,2 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { transcript } = require('./car-mode');
test('voice search submits only final results from the result index', () => { const interim = [{transcript:'wrong'}]; interim.isFinal = false; const final = [{transcript:'Amazing Grace'}]; final.isFinal = true; assert.equal(transcript({results:[interim, final]}), 'Amazing Grace'); assert.equal(transcript({resultIndex:2,results:[final,final]}), ''); assert.equal(transcript({}), ''); });

304
frontend/downloads-page.js Normal file
View File

@@ -0,0 +1,304 @@
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
(function (root) {
'use strict';
const bytes = n => {
if (typeof n !== 'number' || isNaN(n) || n <= 0) return '0 MB';
return n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
};
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatJobStatus(job) {
if (job.status === 'failed') {
return job.error ? `Failed: ${job.error}` : 'Save failed';
}
if (job.status === 'paused') {
return 'Paused — resume when you’re ready';
}
if (job.status === 'queued') {
return 'Waiting for a free slot…';
}
const p = job.progress;
if (p) {
if (p.phase === 'waiting') return 'Waiting for a free slot…';
if (p.phase === 'preparing') return `Preparing on server… ${Math.round((p.elapsed || 0) / 1000)} s`;
if (p.phase === 'verifying') return 'Verifying…';
if (p.total) {
return `${(p.received / 1048576).toFixed(1)} / ${(p.total / 1048576).toFixed(1)} MB`;
}
}
return 'Saving for offline…';
}
function groupJobs(jobs) {
const groups = {
active: [],
queued: [],
paused: [],
failed: []
};
for (const job of jobs || []) {
const st = job.status || 'paused';
if (groups[st]) {
groups[st].push(job);
} else {
groups.paused.push(job);
}
}
return groups;
}
function confirmCancel(job, options) {
const title = job.title || job.id;
const message = `Cancel saving “${title}”?`;
const subtext = 'The part already downloaded will be deleted.';
if (options.showModal && typeof options.showModal === 'function') {
const body = options.doc ? options.doc.createTextNode(subtext) : subtext;
options.showModal(message, body, [
{ label: 'Keep', onClick: options.closeModal || (() => {}) },
{
label: 'Cancel save',
danger: true,
onClick: () => {
options.onAction(job.id, 'cancel');
if (options.closeModal) options.closeModal();
if (options.refresh) options.refresh();
}
}
]);
} else if (typeof root.confirm === 'function') {
if (root.confirm(`${message}\n${subtext}`)) {
options.onAction(job.id, 'cancel');
if (options.refresh) options.refresh();
}
} else {
options.onAction(job.id, 'cancel');
if (options.refresh) options.refresh();
}
}
let watcher;
function render(container, options = {}) {
clearInterval(watcher);
if (!container) return;
const doc = container.ownerDocument || document;
const downloadsApi = options.downloadsApi || root.Downloads;
const jobs = options.jobs || (downloadsApi && downloadsApi.getJobs ? downloadsApi.getJobs() : []);
const onAction = options.onAction || (downloadsApi && downloadsApi.action ? downloadsApi.action.bind(downloadsApi) : () => {});
const onNavigateSaved = options.onNavigateSaved || (() => {});
container.replaceChildren();
if (options.isActive && options.refresh) {
const state = items => JSON.stringify(items.map(j => [j.id, j.status, j.error]));
const signature = state(jobs);
watcher = setInterval(() => {
if (!options.isActive()) { clearInterval(watcher); return; }
if (signature !== state(downloadsApi.getJobs())) options.refresh();
}, 1000);
}
const query = (options.filter || '').toLowerCase();
const visibleJobs = jobs.filter(job => `${job.title || job.id} ${job.channel || ''}`.toLowerCase().includes(query));
if (jobs.length && !visibleJobs.length) {
const message = doc.createElement('p'); message.className = 'status'; message.textContent = 'No downloads match your filter.'; container.append(message); return;
}
if (!jobs.length) {
// Calm SVG Empty State
const empty = doc.createElement('div');
empty.className = 'empty-state dl-empty-state';
empty.innerHTML = `
<div class="empty-icon dl-empty-icon-wrapper" aria-hidden="true">
<svg class="dl-empty-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</div>
<h3 class="empty-title">No active downloads</h3>
<p class="empty-desc">Saves in progress appear here with live status. Videos you save land in <strong>Saved</strong>.</p>
<p class="dl-how-to-save">Save videos for offline listening using the Save button on any track or playlist.</p>
<div class="dl-storage-note">Calculating storage…</div>
`;
const cta = doc.createElement('button');
cta.type = 'button';
cta.className = 'empty-cta dl-saved-cta';
cta.textContent = 'View saved videos';
cta.addEventListener('click', onNavigateSaved);
empty.appendChild(cta);
container.appendChild(empty);
if (downloadsApi && downloadsApi.getFiles) {
Promise.resolve(downloadsApi.getFiles()).then(files => {
const storageNote = empty.querySelector('.dl-storage-note');
if (storageNote && files) {
const playlists = downloadsApi.getPlaylists ? downloadsApi.getPlaylists() : [];
const stats = downloadsApi.breakdown ? downloadsApi.breakdown(files, playlists) : null;
const totalStr = stats ? bytes(stats.total) : bytes(files.reduce((a, b) => a + (b.size || 0), 0));
storageNote.textContent = `Device storage used by saved media: ${totalStr}`;
}
}).catch(() => {
const storageNote = empty.querySelector('.dl-storage-note');
if (storageNote) storageNote.textContent = '';
});
}
return;
}
// Summary Header
const summary = doc.createElement('div');
summary.className = 'saved-summary dl-summary';
const totalCount = jobs.length;
summary.textContent = `${totalCount} download${totalCount === 1 ? '' : 's'}`;
const savedLinkBtn = doc.createElement('button');
savedLinkBtn.type = 'button';
savedLinkBtn.className = 'dl-saved-link';
savedLinkBtn.textContent = 'View saved';
savedLinkBtn.addEventListener('click', onNavigateSaved);
summary.appendChild(savedLinkBtn);
container.appendChild(summary);
// Grouped Consistent Rows
const groups = groupJobs(visibleJobs);
const sections = [
{ key: 'active', title: 'Active downloads' },
{ key: 'queued', title: 'Queued' },
{ key: 'paused', title: 'Paused' },
{ key: 'failed', title: 'Failed' }
];
for (const sec of sections) {
const secJobs = groups[sec.key];
if (!secJobs || !secJobs.length) continue;
const groupDiv = doc.createElement('div');
groupDiv.className = `dl-group dl-group-${sec.key}`;
const groupHeading = doc.createElement('h4');
groupHeading.className = 'dl-group-title';
groupHeading.textContent = `${sec.title} (${secJobs.length})`;
groupDiv.appendChild(groupHeading);
for (const job of secJobs) {
const isPaused = job.status === 'paused';
const isFailed = job.status === 'failed';
const isActive = job.status === 'active' || job.status === 'queued';
const row = doc.createElement('div');
row.className = `card downloading dl-row ${isPaused ? 'dl-paused' : ''} ${isFailed ? 'dl-failed' : ''} ${isActive ? 'dl-active' : ''}`;
row.dataset.id = job.id;
const thumbUrl = job.thumbnail || (options.thumbUrlFor ? options.thumbUrlFor(job.id, job) : '');
const p = job.progress;
const pct = p && p.total ? Math.max(1, Math.min(100, (p.received / p.total) * 100)) : null;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" decoding="async" src="${escapeHtml(thumbUrl)}" alt="" />
<div class="dl-progress"><div class="dl-bar ${pct !== null ? 'determinate' : ''}" style="${pct !== null ? 'width:' + pct + '%' : ''}"></div></div>
</div>
<div class="card-info">
<div class="card-title">${escapeHtml(job.title || job.id)}</div>
<div class="card-channel">${escapeHtml(formatJobStatus(job))}</div>
</div>
`;
const acts = doc.createElement('div');
acts.className = 'dl-actions';
if (isActive) {
const pauseBtn = doc.createElement('button');
pauseBtn.type = 'button';
pauseBtn.className = 'dl-act';
pauseBtn.textContent = 'Pause';
pauseBtn.setAttribute('aria-label', `Pause ${job.title || job.id}`);
pauseBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'pause');
if (options.refresh) options.refresh();
};
acts.appendChild(pauseBtn);
} else if (isPaused) {
const resumeBtn = doc.createElement('button');
resumeBtn.type = 'button';
resumeBtn.className = 'dl-act';
resumeBtn.textContent = 'Resume';
resumeBtn.setAttribute('aria-label', `Resume ${job.title || job.id}`);
resumeBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'resume');
if (options.refresh) options.refresh();
};
acts.appendChild(resumeBtn);
} else if (isFailed) {
const retryBtn = doc.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'dl-act';
retryBtn.textContent = 'Retry';
retryBtn.setAttribute('aria-label', `Retry ${job.title || job.id}`);
retryBtn.onclick = (e) => {
e.stopPropagation();
onAction(job.id, 'retry');
if (options.refresh) options.refresh();
};
acts.appendChild(retryBtn);
}
const cancelBtn = doc.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'dl-act danger';
cancelBtn.textContent = 'Cancel';
cancelBtn.setAttribute('aria-label', `Cancel ${job.title || job.id}`);
cancelBtn.onclick = (e) => {
e.stopPropagation();
confirmCancel(job, {
showModal: options.showModal,
closeModal: options.closeModal,
doc,
onAction,
refresh: options.refresh
});
};
acts.appendChild(cancelBtn);
row.querySelector('.card-info').appendChild(acts);
groupDiv.appendChild(row);
}
container.appendChild(groupDiv);
}
if (groups.paused.length && root.OPFS && root.OPFS.listPartials) {
root.OPFS.listPartials().then(parts => {
for (const p of parts) {
const row = container.querySelector(`.card.dl-paused[data-id="${CSS.escape(p.id)}"]`);
if (!row || !p.size) continue;
const bar = row.querySelector('.dl-bar');
if (bar) {
bar.classList.add('determinate');
bar.style.width = Math.max(1, Math.min(100, (p.received / p.size) * 100)) + '%';
}
const ch = row.querySelector('.card-channel');
if (ch) {
ch.textContent = `Paused at ${(p.received / 1048576).toFixed(1)} / ${(p.size / 1048576).toFixed(1)} MB — resume when you’re ready`;
}
}
}).catch(() => {});
}
}
const api = { render, groupJobs, formatJobStatus, escapeHtml, bytes };
if (typeof module !== 'undefined') module.exports = api; else root.DownloadsPage = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,50 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const DownloadsPage = require('./downloads-page.js');
test('groupJobs categorizes active, queued, paused, and failed jobs correctly', () => {
const jobs = [
{ id: '1', title: 'Job 1', status: 'active' },
{ id: '2', title: 'Job 2', status: 'queued' },
{ id: '3', title: 'Job 3', status: 'paused' },
{ id: '4', title: 'Job 4', status: 'failed', error: 'Network error' }
];
const groups = DownloadsPage.groupJobs(jobs);
assert.equal(groups.active.length, 1);
assert.equal(groups.queued.length, 1);
assert.equal(groups.paused.length, 1);
assert.equal(groups.failed.length, 1);
assert.equal(groups.active[0].id, '1');
assert.equal(groups.failed[0].error, 'Network error');
});
test('formatJobStatus produces plain text labels without emojis for all job states', () => {
assert.equal(DownloadsPage.formatJobStatus({ status: 'failed', error: 'Connection lost' }), 'Failed: Connection lost');
assert.equal(DownloadsPage.formatJobStatus({ status: 'failed' }), 'Save failed');
assert.equal(DownloadsPage.formatJobStatus({ status: 'paused' }), 'Paused — resume when you’re ready');
assert.equal(DownloadsPage.formatJobStatus({ status: 'queued' }), 'Waiting for a free slot…');
assert.equal(
DownloadsPage.formatJobStatus({ status: 'active', progress: { received: 10485760, total: 52428800 } }),
'10.0 / 50.0 MB'
);
assert.equal(
DownloadsPage.formatJobStatus({ status: 'active', progress: { phase: 'preparing', elapsed: 3000 } }),
'Preparing on server… 3 s'
);
assert.equal(DownloadsPage.formatJobStatus({ status: 'active', progress: { phase: 'verifying' } }), 'Verifying…');
assert.equal(DownloadsPage.formatJobStatus({ status: 'active' }), 'Saving for offline…');
});
test('bytes formatting handles bytes, MB, GB and invalid input safely', () => {
assert.equal(DownloadsPage.bytes(0), '0 MB');
assert.equal(DownloadsPage.bytes(10485760), '10.0 MB');
assert.equal(DownloadsPage.bytes(1073741824), '1.0 GB');
assert.equal(DownloadsPage.bytes(-100), '0 MB');
assert.equal(DownloadsPage.bytes(null), '0 MB');
});
test('escapeHtml escapes dangerous HTML characters', () => {
assert.equal(DownloadsPage.escapeHtml('<script>alert("xss")</script>'), '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
assert.equal(DownloadsPage.escapeHtml('Tom & Jerry'), 'Tom &amp; Jerry');
});

55
frontend/downloads.js Normal file
View File

@@ -0,0 +1,55 @@
/* Download controls and device storage accounting. */
(function(root) {
'use strict';
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
function breakdown(files, playlists) {
const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map();
for (const f of files) { const size = Math.max(0, Number(f.size) || 0); kinds[f.kind in kinds ? f.kind : 'video'] += size; byId.set(f.id, (byId.get(f.id) || 0) + size); }
return { total: Object.values(kinds).reduce((a, b) => a + b, 0), kinds, playlists: playlists.map(p => ({ id: p.id, name: p.name || p.title || 'Playlist', size: [...new Set((p.videos || []).map(v => v.id))].reduce((n, id) => n + (byId.get(id) || 0), 0) })) };
}
let adapter, total = 0;
function configure(options) {
adapter = options;
root.SettingsSections.register({ id: 'downloads-storage', title: 'Downloads & storage', cluster: 'Library & storage', description: 'Active queued paused failed saves, disk usage and playlist sizes', icon: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>', summary: () => `${adapter.slots()} at once · ${bytes(total)}`, render(container) {
const doc = container.ownerDocument;
const heading = doc.createElement('h3'); heading.textContent = 'Downloads & storage';
const jobs = doc.createElement('div'); jobs.className = 'download-manager'; jobs.setAttribute('aria-label', 'Download manager');
const storage = doc.createElement('div'); storage.className = 'download-storage';
container.append(heading, jobs, storage);
let alive = true, signature = '';
function paint() {
const items = adapter.jobs(); const next = JSON.stringify(items); if (next === signature) return; signature = next;
jobs.replaceChildren();
if (!items.length) { const p = doc.createElement('p'); p.textContent = 'No pending downloads.'; jobs.append(p); }
for (const job of items) {
const row = doc.createElement('div'); row.className = 'download-job';
const title = doc.createElement('b'); title.textContent = job.title || job.id;
const status = doc.createElement('small'); status.textContent = job.error || job.status;
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
const actions = doc.createElement('div');
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
}
row.append(title, status, progress, actions); jobs.append(row);
}
}
async function disk() {
try {
const [files, estimate] = await Promise.all([adapter.files(), root.navigator.storage?.estimate?.() || {}]); if (!alive) return;
const stats = breakdown(files, adapter.playlists()); total = stats.total; storage.replaceChildren();
for (const text of [`Device media: ${bytes(total)}`, `Video ${bytes(stats.kinds.video)} · Audio ${bytes(stats.kinds.audio)} · EQ renders ${bytes(stats.kinds.eq)}`, estimate.quota ? `Available browser storage: ${bytes(Math.max(0, estimate.quota - (estimate.usage || 0)))}` : 'Available browser storage is not reported.', ...stats.playlists.map(p => `${p.name}: ${bytes(p.size)}`), 'Playlist totals include shared files; device totals count each file once. Browser quota is not total device free space.']) { const p = doc.createElement('p'); p.textContent = text; storage.append(p); }
storage.dispatchEvent(new Event('change', { bubbles: true }));
} catch { storage.textContent = 'Storage details are unavailable on this device.'; }
}
paint(); disk(); const timer = setInterval(paint, 1000), diskTimer = setInterval(disk, 15000);
return () => { alive = false; clearInterval(timer); clearInterval(diskTimer); };
} });
}
function getJobs() { return adapter && typeof adapter.jobs === 'function' ? adapter.jobs() : []; }
function action(id, act) { if (adapter && typeof adapter.action === 'function') return adapter.action(id, act); }
function getFiles() { return adapter && typeof adapter.files === 'function' ? adapter.files() : Promise.resolve([]); }
function getPlaylists() { return adapter && typeof adapter.playlists === 'function' ? adapter.playlists() : []; }
function getSlots() { return adapter && typeof adapter.slots === 'function' ? adapter.slots() : 4; }
function snapshot() { return { jobs: getJobs(), slots: getSlots(), playlists: getPlaylists() }; }
const api = { configure, breakdown, bytes, getJobs, action, getFiles, getPlaylists, getSlots, snapshot }; if (typeof module !== 'undefined') module.exports = api; else root.Downloads = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,17 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { breakdown, bytes } = require('./downloads.js');
test('storage counts media once and shared playlist files within each playlist', () => {
const result = breakdown([{ id: 'a', size: 100, kind: 'video' }, { id: 'a', size: 20, kind: 'eq' }, { id: 'b', size: 30, kind: 'audio' }], [{ id: 'one', videos: [{ id: 'a' }, { id: 'a' }] }, { id: 'two', videos: [{ id: 'a' }, { id: 'b' }] }]);
assert.equal(result.total, 150); assert.deepEqual(result.kinds, { video: 100, audio: 30, eq: 20 }); assert.deepEqual(result.playlists.map(p => p.size), [120, 150]);
});
test('storage ignores invalid and negative sizes', () => { assert.equal(breakdown([{ id: 'a', size: -10 }, { id: 'b', size: 'bad' }], []).total, 0); assert.equal(bytes(1073741824), '1.0 GB'); });
test('pausing a worker terminates it once and ignores later completion', async () => {
const vm = require('node:vm'), fs = require('node:fs'); let worker;
class Worker { constructor() { worker = this; this.terminated = 0; } postMessage() {} terminate() { this.terminated++; } }
const window = {};
vm.runInNewContext(fs.readFileSync(require.resolve('./opfs.js'), 'utf8'), { window, navigator: {}, Worker, URL, AbortController });
const controller = new AbortController(); const result = window.OPFS.downloadVideo('video', '/download', { signal: controller.signal });
controller.abort(); assert.equal((await result).paused, true); assert.equal(worker.terminated, 1);
worker.onmessage({ data: { type: 'done' } }); assert.equal(worker.terminated, 1);
});

34
frontend/eq-core.js Normal file
View File

@@ -0,0 +1,34 @@
/* Shared live/offline EQ definitions and legacy curve migration. */
(function(root) {
'use strict';
const frequencies = [31,62,125,250,500,1000,2000,4000,8000,16000];
const bands = frequencies.map((f,i) => ({ f, label: f >= 1000 ? `${f/1000}k` : String(f), type: i === 0 ? 'lowshelf' : i === 9 ? 'highshelf' : 'peaking' }));
const clamp = n => Math.max(-12, Math.min(12, Number(n) || 0));
function migrate(values) {
if (!Array.isArray(values)) return frequencies.map(() => 0);
if (values.length !== 5) return frequencies.map((_,i) => clamp(values[i]));
const old = [60,250,1000,4000,12000], curve = values.map(clamp);
return frequencies.map(f => { if (f <= old[0]) return curve[0]; if (f >= old[4]) return curve[4]; const i = old.findIndex(x => x >= f), weight = Math.log(f / old[i-1]) / Math.log(old[i] / old[i-1]); return Math.round((curve[i-1] * (1-weight) + curve[i] * weight) * 10) / 10; });
}
const presets = {
flat: { name: 'Flat', g: frequencies.map(() => 0) },
bass: { name: 'Bass boost', g: [5,7,5,3,1,0,0,0,0,0] },
vocal: { name: 'Vocal clarity', g: [-3,-4,-2,0,1,2,3,4,1,0] },
acoustic: { name: 'Acoustic live', g: [-2,-1,0,-2,-1,1,2,2,1,0] },
classical: { name: 'Classical', g: migrate([4,2,-1,1,3]) },
treble: { name: 'Treble boost', g: [0,0,0,0,0,0,1,3,5,6] },
};
const width = value => Math.max(0, Math.min(100, Number(value) || 0));
function createWidener(context, source, value = 0) {
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2), delay = context.createDelay(.05);
source.connect(splitter); splitter.connect(output, 0, 0); splitter.connect(delay, 1); delay.connect(output, 0, 1);
delay.delayTime.value = width(value) / 100 * .015;
return { output, set: next => delay.delayTime.setTargetAtTime(width(next) / 100 * .015, context.currentTime, .03) };
}
function register(adapter) { root.SettingsSections.register({ id: 'equalizer', title: 'Equalizer & stereo', cluster: 'Listening', summary: () => `${presets[adapter.settings().eq?.preset]?.name || 'Custom'} · ${width(adapter.settings().stereoWidth) ? 'Widened' : 'Original stereo'}`, render(container) {
const doc = container.ownerDocument, button = doc.createElement('button'); button.type = 'button'; button.className = 'settings-back'; button.textContent = 'Open ten-band equalizer'; button.onclick = adapter.open;
const label = doc.createElement('label'); label.className = 'set-row'; label.textContent = 'Headphone stereo widener (optional)'; const input = doc.createElement('input'); input.type = 'range'; input.min = '0'; input.max = '100'; input.value = String(width(adapter.settings().stereoWidth)); input.setAttribute('aria-label', 'Headphone stereo width'); const out = doc.createElement('output'); out.textContent = `${input.value}%`; input.oninput = () => { adapter.settings().stereoWidth = Number(input.value); out.textContent = `${input.value}%`; adapter.persist(); adapter.apply(); }; label.append(input, out);
const note = doc.createElement('p'); note.textContent = 'Off at 0%. Adds up to 15 ms delay to the right channel for headphones. May colour the sound on speakers or when summed to mono. On iPhone, saved songs use processed copies; streamed songs require Live sound processing.'; container.append(button,label,note);
} }); }
const api = { bands, presets, migrate, width, createWidener, register }; if (typeof module !== 'undefined') module.exports = api; else root.EqCore = api;
})(typeof window !== 'undefined' ? window : globalThis);

4
frontend/eq-core.test.js Normal file
View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { bands, presets, migrate, width } = require('./eq-core');
test('ten bands and every preset share one frequency definition', () => { assert.deepEqual(bands.map(b=>b.f), [31,62,125,250,500,1000,2000,4000,8000,16000]); for (const preset of Object.values(presets)) assert.equal(preset.g.length, 10); });
test('legacy curves interpolate in log frequency and retain anchor values', () => { const result = migrate([6,2,-2,4,0]); assert.equal(result[0],6); assert.equal(result[3],2); assert.equal(result[5],-2); assert.equal(result[7],4); assert.equal(result[9],0); assert.ok(result[4] > -2 && result[4] < 2); assert.deepEqual(migrate([0,0,0,0,0]), presets.flat.g); });
test('current curves are stable and clamp bad gains; widening defaults off', () => { assert.deepEqual(migrate(presets.acoustic.g), presets.acoustic.g); assert.equal(migrate([99,-99,NaN])[0],12); assert.equal(migrate([99,-99,NaN])[1],-12); assert.equal(width(undefined),0); assert.equal(width(500),100); });

View File

@@ -0,0 +1,69 @@
/* Draw legacy control glyphs only in Glass; restore them on theme changes. */
(function(root) {
'use strict';
const paths = {
add:'<path d="M12 5v14M5 12h14"/>', close:'<path d="m6 6 12 12M18 6 6 18"/>',
search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
save:'<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
play:'<path d="m8 5 11 7-11 7z"/>', pause:'<path d="M8 5v14M16 5v14"/>',
next:'<path d="m5 5 10 7-10 7zM19 5v14"/>', previous:'<path d="m19 5-10 7 10 7zM5 5v14"/>',
repeat:'<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3"/>',
edit:'<path d="m14 5 5 5M4 20l5-1L21 7l-4-4L5 15z"/>',
lyrics:'<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/>',
share:'<path d="M12 16V3m-4 4 4-4 4 4M5 12v9h14v-9"/>',
copy:'<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>',
check:'<path d="m5 12 4 4L19 6"/>', folder:'<path d="M3 7V5h7l2 3h9v12H3z"/>',
settings:'<path d="M4 6h16M4 12h16M4 18h16"/><circle cx="8" cy="6" r="2"/><circle cx="16" cy="12" r="2"/><circle cx="10" cy="18" r="2"/>',
chapters:'<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
shuffle:'<path d="m18 3 3 3-3 3M3 6h3c5 0 7 12 12 12h3m-3-3 3 3-3 3M3 18h3c2 0 4-3 6-6s4-6 6-6h3"/>',
down:'<path d="m6 9 6 6 6-6"/>', right:'<path d="m9 6 6 6-6 6"/>', back:'<path d="M20 12H4m6-6-6 6 6 6"/>', more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
devices:'<rect x="3" y="4" width="14" height="12" rx="2"/><path d="M7 20h6M10 16v4"/><rect x="17" y="10" width="5" height="11" rx="1"/>'
};
const glyphs = {'+':'add','✕':'close','×':'close','✖':'close','🔍':'search','🔎':'search','⬇':'save','⤓':'save','💾':'save','▶':'play','⏸':'pause','⏭':'next','⏮':'previous','↻':'repeat','↺':'repeat','✎':'edit','✏':'edit','📝':'edit','🎤':'lyrics','↗':'share','⧉':'copy','✓':'check','📁':'folder','⚙':'settings','📋':'chapters','🕘':'clock','🔀':'shuffle','📡':'devices','▾':'down','▸':'right','←':'back','⋯':'more'};
function leadingGlyph(text) {
const prefix = text.match(/^\s*/)[0], rest = text.slice(prefix.length);
const glyph = Object.keys(glyphs).find(key => rest.startsWith(key));
if (!glyph) return null;
const length = glyph.length + (rest[glyph.length] === '\uFE0F' ? 1 : 0);
return { kind:glyphs[glyph], original:prefix+rest.slice(0,length), rest:rest.slice(length) };
}
function mount(doc) {
const decorations = new Set(); let scheduled=false;
const selector = '#listPane button, .sidebar button, .smart-item .pl-name, .notes-tab, .notes-fold, .mini-btn';
const svg = kind => { const icon=doc.createElementNS('http://www.w3.org/2000/svg','svg'); icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.classList.add('glass-control-icon');icon.innerHTML=paths[kind];return icon; };
const observer=new root.MutationObserver(schedule), themeObserver=new root.MutationObserver(schedule);
function restore(record) {
record.svg.remove(); if(record.original.isConnected)record.original.replaceWith(doc.createTextNode(record.original.textContent));
if(record.addedLabel && record.parent.isConnected)record.parent.removeAttribute('aria-label');
}
function paint() {
scheduled=false;observer.disconnect();
const active=doc.documentElement.dataset.layout==='glass-stage';
for(const record of decorations)if(!active || !record.svg.isConnected){restore(record);decorations.delete(record);}
if(active) for(const parent of doc.querySelectorAll(selector)) {
// Existing SVGs already express the action; only legacy glyphs change.
if(parent.querySelector('svg:not(.glass-control-icon),.legacy-nav-icon'))continue;
for(const node of [...parent.childNodes]) {
if(node.nodeType!==3)continue;const match=leadingGlyph(node.textContent);if(!match)continue;
const icon=svg(match.kind), original=doc.createElement('span'); original.className='glass-original-glyph';original.textContent=match.original;
node.textContent=match.rest;parent.insertBefore(icon,node);parent.insertBefore(original,node);
const addedLabel=parent.tagName==='BUTTON'&&!parent.hasAttribute('aria-label')&&!parent.textContent.replace(match.original,'').trim();
if(addedLabel)parent.setAttribute('aria-label',parent.title || ({play:'Play',pause:'Pause',close:'Close',add:'Add',save:'Save'}[match.kind] || match.kind));
decorations.add({parent,svg:icon,original,addedLabel});break;
}
if(parent.matches('.notes-tab')&&!parent.querySelector('svg')&&paths[parent.dataset.tab==='markers'?'chapters':parent.dataset.tab]) {
const kind=parent.dataset.tab==='markers'?'chapters':parent.dataset.tab,icon=svg(kind);parent.prepend(icon);
const original=doc.createElement('span');decorations.add({parent,svg:icon,original,addedLabel:false});
}
}
if(active)observer.observe(doc.body,{childList:true,subtree:true,characterData:true});
}
function schedule(){if(!scheduled){scheduled=true;root.queueMicrotask(paint);}}
themeObserver.observe(doc.documentElement,{attributes:true,attributeFilter:['data-layout']});paint();
return ()=>{observer.disconnect();themeObserver.disconnect();for(const record of decorations)restore(record);};
}
if(typeof module!=='undefined')module.exports={leadingGlyph,paths};
else if(root.document.readyState==='loading')root.document.addEventListener('DOMContentLoaded',()=>mount(root.document),{once:true});
else mount(root.document);
})(typeof window!=='undefined'?window:globalThis);

View File

@@ -0,0 +1,9 @@
const test=require('node:test'),assert=require('node:assert/strict');
const {leadingGlyph}=require('./glass-controls.js');
test('glass icon substitution preserves leading whitespace and remaining labels',()=>{
assert.deepEqual(leadingGlyph(' ⬇ Save'),{kind:'save',original:' ⬇',rest:' Save'});
assert.deepEqual(leadingGlyph('⚙️ Settings'),{kind:'settings',original:'⚙️',rest:' Settings'});
assert.equal(leadingGlyph('A song with 🔍 in its title'),null);
assert.equal(leadingGlyph('Clear all'),null);
assert.equal(leadingGlyph('⏸').kind,'pause');
});

View File

@@ -13,7 +13,7 @@
<meta name="apple-mobile-web-app-title" content="YT Player" /> <meta name="apple-mobile-web-app-title" content="YT Player" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';" content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
/> />
<title>YT Player</title> <title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
@@ -27,6 +27,12 @@
<link rel="stylesheet" href="theme-glass.css" /> <link rel="stylesheet" href="theme-glass.css" />
<link rel="stylesheet" href="theme-classic.css" /> <link rel="stylesheet" href="theme-classic.css" />
<link rel="stylesheet" href="recommendations.css" /> <link rel="stylesheet" href="recommendations.css" />
<link rel="stylesheet" href="settings-sections.css" />
<link rel="stylesheet" href="lower-third.css" />
<link rel="stylesheet" href="car-mode.css" />
<link rel="stylesheet" href="offline-pages.css" />
<link rel="stylesheet" href="saved-page.css" />
<link rel="stylesheet" href="theme-glass-controls.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -639,6 +645,7 @@
<script src="opfs.js"></script> <script src="opfs.js"></script>
<script src="video-edit.js"></script> <script src="video-edit.js"></script>
<script src="lyrics-core.js"></script> <script src="lyrics-core.js"></script>
<script src="lyrics-display.js"></script>
<script src="flag-ui.js"></script> <script src="flag-ui.js"></script>
<script src="export.js"></script> <script src="export.js"></script>
<script src="stats-core.js"></script> <script src="stats-core.js"></script>
@@ -652,6 +659,22 @@
<script src="p2p-transfer.js"></script> <script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script> <script src="async-guard.js"></script>
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="settings-sections.js"></script>
<script src="downloads.js"></script>
<script src="downloads-page.js"></script>
<script src="saved-page.js"></script>
<script src="server-backfill.js"></script>
<script src="glass-controls.js"></script>
<script src="party-dj.js"></script>
<script src="lyrics-window.js"></script>
<script src="midi.js"></script>
<script src="lower-third.js"></script>
<script src="vocal-reducer.js"></script>
<script src="eq-core.js"></script>
<script src="piano-core.js"></script>
<script src="piano.js"></script>
<script src="setlist-import.js"></script>
<script src="car-mode.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

10
frontend/lower-third.css Normal file
View File

@@ -0,0 +1,10 @@
html.obs-overlay, .obs-overlay body, .obs-overlay #presenterView { background: transparent !important; }
.obs-overlay body > *:not(#presenterView):not(#obsLowerThird) { display: none !important; }
.obs-overlay #presenterView { pointer-events: none; }
.obs-overlay #presenterView > * { visibility: hidden; }
#obsLowerThird { position: fixed; inset: auto 6vw 7vh; z-index: 10000; text-align: center; color: white; font-family: system-ui, sans-serif; font-weight: 650; font-size: clamp(24px, 3.4vw, 70px); line-height: 1.15; text-shadow: 0 2px 4px #000, 0 0 14px #000, 0 0 3px #000; pointer-events: none; }
.obs-line { white-space: pre-line; animation: obs-cue-in 220ms ease-out; }
.obs-next { font-size: .7em; opacity: .8; margin-top: .3em; }
.obs-line small { display: block; font-size: .65em; opacity: .8; margin-top: .15em; }
@keyframes obs-cue-in { from { opacity: 0; transform: translateY(12px); } to { transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .obs-line { animation: none; } }

16
frontend/lower-third.js Normal file
View File

@@ -0,0 +1,16 @@
/* Broadcast lyrics rendered through the existing presenter relay. */
(function(root) {
'use strict';
function cuePair(lines, index) { const current = lines[index]?.kind === 'line' ? lines[index] : null; const next = lines.slice(Math.max(0, index + 1)).find(l => l.kind === 'line') || null; return { current, next }; }
function url(origin, code) { const u = new URL('/', origin); u.searchParams.set('overlay', 'lowerthird'); u.searchParams.set('code', String(code)); return u.href; }
const active = () => new URLSearchParams(root.location?.search).get('overlay') === 'lowerthird';
let box, signature = '';
function paint(lines, index) {
if (!box) { box = root.document.createElement('div'); box.id = 'obsLowerThird'; box.setAttribute('aria-live', 'polite'); root.document.body.append(box); }
const pair = cuePair(lines, index), key = JSON.stringify(pair); if (key === signature) return; signature = key;
box.replaceChildren();
for (const [role, cue] of Object.entries(pair)) { const p = root.document.createElement('div'); p.className = `obs-line obs-${role}`; const parts = root.LyricsCore.display(cue); p.textContent = parts.text; if (parts.secondary || parts.phonetic) { const secondary = root.document.createElement('small'); secondary.textContent = [parts.secondary, parts.phonetic].filter(Boolean).join(' · '); p.append(secondary); } box.append(p); }
}
function register(open) { root.SettingsSections.register({ id: 'broadcast', title: 'Presenter & broadcast', cluster: 'Live & worship', summary: () => 'Projector · OBS lower third', render(container) { const doc = container.ownerDocument, p = doc.createElement('p'); p.textContent = 'Start a remote-controlled screen, then copy its OBS browser-source URL. Set the browser source to 1920 × 1080. The output follows the host and remains transparent. Pairing codes expire; an already paired source reconnects until you unpair it.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open presenter & remote controls'; b.onclick = open; container.append(p, b); } }); }
const api = { active, paint, cuePair, url, register }; if (typeof module !== 'undefined') module.exports = api; else { root.LowerThird = api; if (active()) root.document.documentElement.classList.add('obs-overlay'); }
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,3 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { cuePair, url } = require('./lower-third');
test('lower third skips section headings for next lyrics and does not invent current lyrics', () => { const lines = [{ kind: 'line', text: 'First' }, { kind: 'section', text: 'Chorus' }, { kind: 'line', text: 'Second' }]; assert.equal(cuePair(lines, 0).next.text, 'Second'); assert.equal(cuePair(lines, 1).current, null); assert.equal(cuePair(lines, -1).next.text, 'First'); assert.equal(cuePair(lines, 2).next, null); });
test('OBS output URL encodes the code without adding host credentials', () => { const u = new URL(url('https://example.test/subpath', '123456')); assert.equal(u.pathname, '/'); assert.equal(u.searchParams.get('overlay'), 'lowerthird'); assert.equal(u.searchParams.get('code'), '123456'); });

View File

@@ -56,6 +56,11 @@
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean); return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
} }
function display(line) {
const text = String(line?.text || ''), divider = text.indexOf(' / ');
return { text: divider < 0 ? text : text.slice(0, divider).trim(), secondary: line?.secondary || (divider < 0 ? '' : text.slice(divider + 3).trim()), phonetic: line?.phonetic || '' };
}
// Editor text → { lines, tags, offset }. // Editor text → { lines, tags, offset }.
function fromText(text) { function fromText(text) {
const lines = []; const lines = [];
@@ -65,8 +70,14 @@
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) { for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim(); let rest = rawLine.trim();
if (!rest) continue; if (!rest) continue;
if (/^!pinyin:\s*/i.test(rest) && group.some(l => l.kind === 'line')) {
const value = rest.replace(/^!pinyin:\s*/i, '').trim();
for (const line of group) if (value) line.phonetic = line.phonetic ? line.phonetic + '\n' + value : value;
continue;
}
if (rest.startsWith('| ') && group.length) { if (rest.startsWith('| ') && group.length) {
for (const line of group) line.text += '\n' + rest.slice(2).trim(); const extra = display({ text: rest.slice(2).trim() });
for (const line of group) { line.text += '\n' + extra.text; if (extra.secondary) line.secondary = line.secondary ? line.secondary + '\n' + extra.secondary : extra.secondary; }
continue; continue;
} }
group = []; group = [];
@@ -92,6 +103,7 @@
if (!rest) continue; if (!rest) continue;
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind })) group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
: [{ t: null, text: rest, kind }]; : [{ t: null, text: rest, kind }];
if (kind === 'line') for (const line of group) { const parts = display(line); line.text = parts.text; if (parts.secondary) line.secondary = parts.secondary; }
lines.push(...group); lines.push(...group);
} }
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in // A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
@@ -108,8 +120,11 @@
for (const l of (doc && doc.lines) || []) { for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `; const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : ''; const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
const parts = String(l.text || '').split('\n'); const parts = (l.kind === 'line' ? display(l) : { text: l.text || '', secondary: '' });
out.push(stamp + mark + parts[0], ...parts.slice(1).map(part => '| ' + part)); const primary = parts.text.split('\n'), secondary = parts.secondary.split('\n');
const combined = primary.map((part, i) => part + (secondary[i] ? ' / ' + secondary[i] : ''));
out.push(stamp + mark + combined[0], ...combined.slice(1).map(part => '| ' + part));
if (l.phonetic) out.push(...String(l.phonetic).split('\n').map(part => '!pinyin: ' + part));
} }
return out.join('\n'); return out.join('\n');
} }
@@ -162,7 +177,7 @@
} }
const LyricsCore = { const LyricsCore = {
parseStamp, fmtStamp, fmtClock, fromText, toText, parseStamp, fmtStamp, fmtClock, fromText, toText, display,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps, activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
}; };

View File

@@ -91,3 +91,13 @@ test('multiline cues round-trip without adding timestamps or active rows', () =>
assert.strictEqual(repeated.lines.length, 2); assert.strictEqual(repeated.lines.length, 2);
assert.strictEqual(repeated.lines[1].text, doc.lines[0].text); assert.strictEqual(repeated.lines[1].text, doc.lines[0].text);
}); });
test('bilingual lyrics and pinyin share one timed cue and round-trip', () => {
const doc = LC.fromText('[1:23.45] 主你的恩典 / Your grace is enough\n!pinyin: zhǔ nǐ de ēndiǎn');
assert.equal(doc.lines.length, 1); assert.equal(doc.lines[0].t,83.45); assert.equal(doc.lines[0].secondary,'Your grace is enough'); assert.equal(doc.lines[0].phonetic,'zhǔ nǐ de ēndiǎn');
assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
});
test('translations preserve grouped breaks, multi-stamps and legacy slash text', () => {
const doc = LC.fromText('[0:01][0:10] 主 / Lord\n| 恩典 / Grace\n!pinyin: zhǔ\n!pinyin: ēndiǎn');
assert.equal(doc.lines.length,2); assert.equal(doc.lines[1].secondary,'Lord\nGrace'); assert.equal(doc.lines[1].phonetic,'zhǔ\nēndiǎn'); assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
assert.equal(LC.fromText('and/or').lines[0].text,'and/or'); assert.equal(LC.fromText('! Band cue').lines[0].kind,'cue');
});

View File

@@ -0,0 +1,8 @@
(function(root) {
'use strict';
const core = typeof module !== 'undefined' ? require('./lyrics-core') : root.LyricsCore;
const escape = value => String(value || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c]);
function html(line) { const parts = core.display(line); return `<span class="lyric-primary">${escape(parts.text)}</span>${parts.secondary ? `<small class="lyric-secondary">${escape(parts.secondary)}</small>` : ''}${parts.phonetic ? `<small class="lyric-secondary lyric-phonetic">${escape(parts.phonetic)}</small>` : ''}`; }
function register() { root.SettingsSections.register({ id: 'lyrics-language', title: 'Lyric languages', cluster: 'Live & worship', summary: () => 'Translations · phonetic lines', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'In the lyrics editor, write “Primary lyric / Translation” in one timed cue. Add “!pinyin: pronunciation” immediately below it. Both secondary lines follow the primary timestamp and appear smaller in Service, stage lyrics, Presenter and OBS output. Shift+Enter still creates a tight line break within one cue.'; container.append(p); } }); }
const api = { html, register }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsDisplay = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,2 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { html } = require('./lyrics-display');
test('primary and muted secondary lyric markup escape all content', () => { const result = html({ text:'<主>', secondary:'Grace & mercy', phonetic:'"zhǔ"' }); assert.match(result,/lyric-primary">&lt;主&gt;/); assert.match(result,/lyric-secondary">Grace &amp; mercy/); assert.match(result,/lyric-phonetic">&quot;zhǔ&quot;/); assert.doesNotMatch(result,/<主>/); });

40
frontend/lyrics-window.js Normal file
View File

@@ -0,0 +1,40 @@
/* Floating synced lyrics; opt-in and feature detected. */
(function(root) {
'use strict';
function support(environment) { return environment.documentPiP ? 'document' : environment.canvas && environment.videoPiP ? 'video' : null; }
function wrap(text, measure, width) { const result = []; for (const paragraph of String(text || '').split('\n')) { let line = ''; for (const word of paragraph.split(/\s+/)) { const next = line ? `${line} ${word}` : word; if (line && measure(next) > width) { result.push(line); line = word; } else line = next; } result.push(line); } return result; }
let pipWindow = null, cleanup = () => {}, adapter;
const mode = () => support({ documentPiP: !!root.documentPictureInPicture?.requestWindow, canvas: !!root.HTMLCanvasElement?.prototype.captureStream, videoPiP: root.document?.pictureInPictureEnabled && !!root.HTMLVideoElement?.prototype.requestPictureInPicture });
async function open() {
if (pipWindow || root.document.pictureInPictureElement?.dataset.lyricsPip) { cleanup(); return; }
if (!mode()) return;
try {
adapter.prepare();
if (mode() === 'document') {
pipWindow = await root.documentPictureInPicture.requestWindow({ width: 420, height: 500 });
const doc = pipWindow.document; doc.documentElement.dataset.layout = root.document.documentElement.dataset.layout; doc.documentElement.dataset.theme = root.document.documentElement.dataset.theme;
for (const link of root.document.querySelectorAll('link[rel="stylesheet"]')) { const copy = doc.createElement('link'); copy.rel = 'stylesheet'; copy.href = link.href; doc.head.append(copy); }
const style = doc.createElement('style'); style.textContent = 'body{margin:0;background:var(--bg,#151719);color:var(--text,#fff);display:flex;flex-direction:column;height:100vh;overflow:hidden}.lyrics-floating-view{position:relative!important;inset:auto!important;flex:1;min-height:0;display:flex!important;flex-direction:column!important;width:100%!important}.lyrics-floating-view .sl-list{flex:1;min-height:0;height:auto!important}.lyrics-floating-controls{display:flex;gap:8px;padding:12px}.lyrics-floating-controls button{flex:1;min-height:48px;background:var(--bg-2,#25292d);color:inherit;border:1px solid var(--line,#555);border-radius:8px}'; doc.head.append(style);
const view = adapter.view(), marker = root.document.createComment('floating lyrics'); view.before(marker); view.classList.add('lyrics-floating-view'); doc.body.append(view);
const controls = doc.createElement('div'); controls.className = 'lyrics-floating-controls';
for (const [label, action] of [['Play / Pause', adapter.toggle], ['Next', adapter.next]]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = action; controls.append(b); } doc.body.append(controls);
const win = pipWindow; let restored = false;
cleanup = () => { if (restored) return; restored = true; marker.replaceWith(view); view.classList.remove('lyrics-floating-view'); pipWindow = null; adapter.refresh(); if (!win.closed) win.close(); };
win.addEventListener('pagehide', cleanup, { once: true }); win.addEventListener('resize', adapter.refresh); adapter.refresh();
} else {
const canvas = root.document.createElement('canvas'); canvas.width = 840; canvas.height = 500; const ctx = canvas.getContext('2d');
const draw = () => { ctx.fillStyle = '#141619'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.font = '32px system-ui'; ctx.textAlign = 'center'; const lines = wrap(adapter.line(), t => ctx.measureText(t).width, 760); lines.slice(0, 8).forEach((line, i) => ctx.fillText(line, 420, 250 + (i - (Math.min(lines.length, 8) - 1) / 2) * 40)); };
draw(); const stream = canvas.captureStream(10), video = root.document.createElement('video'); video.dataset.lyricsPip = '1'; video.muted = true; video.playsInline = true; video.srcObject = stream; video.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none'; root.document.body.append(video);
const timer = setInterval(draw, 100); let closed = false;
cleanup = () => { if (closed) return; closed = true; clearInterval(timer); stream.getTracks().forEach(t => t.stop()); video.srcObject = null; video.remove(); if (root.document.pictureInPictureElement === video) root.document.exitPictureInPicture().catch(() => {}); };
video.addEventListener('leavepictureinpicture', cleanup, { once: true }); await video.play(); await video.requestPictureInPicture();
}
} catch (error) { cleanup(); adapter.toast(`Floating lyrics: ${error.message}`); }
}
function configure(options) {
adapter = options; const supported = !!mode();
for (const id of ['stageLyricsAuto', 'serviceAutoscrollBtn', 'pipBtn']) { const anchor = root.document.getElementById(id); if (!anchor || !supported) continue; const button = root.document.createElement('button'); button.type = 'button'; button.className = 'np-btn'; button.textContent = '⧉ Lyrics'; button.title = 'Open floating lyrics'; button.setAttribute('aria-label', 'Open floating lyrics window'); button.onclick = open; anchor.after(button); }
root.SettingsSections.register({ id: 'floating-lyrics', title: 'Floating lyrics', cluster: 'Live & worship', summary: () => mode() === 'document' ? 'Interactive window available' : mode() ? 'Lyric video PiP available' : 'Not supported on this browser', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = mode() === 'document' ? 'Open synced lyrics with seeking, Play/Pause and Next in an always-on-top window.' : mode() ? 'This browser supports a lyric-video window. Playback controls remain in the player.' : 'Floating lyrics are not supported on this browser.'; container.append(p); if (supported) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open floating lyrics'; b.onclick = open; container.append(b); } } });
}
const api = { configure, support, wrap, element: id => pipWindow?.document.getElementById(id) || null }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsWindow = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict');
const { support, wrap } = require('./lyrics-window');
test('floating lyrics prefer document PiP, fall back only when capture and video PiP work', () => { assert.equal(support({ documentPiP: true }), 'document'); assert.equal(support({ canvas: true, videoPiP: true }), 'video'); assert.equal(support({ canvas: true }), null); });
test('canvas lyric wrapping preserves cue line breaks', () => { assert.deepEqual(wrap('Because You are God\nYou can do anything', t => t.length, 12), ['Because You', 'are God', 'You can do', 'anything']); });

37
frontend/midi.js Normal file
View File

@@ -0,0 +1,37 @@
/* MIDI pedal learning. No MIDI access until explicitly enabled. */
(function(root) {
'use strict';
const actions = { toggle: 'Play / Pause', next: 'Next in setlist', repeat: 'Repeat current section', loop: 'Set / toggle A–B loop', panic: 'Panic — fade to silence' };
function decode(data) { if (!data || data.length < 3) return null; const type = data[0] & 240, channel = data[0] & 15; if (![128,144,176].includes(type)) return null; return { key: `${type === 176 ? 'cc' : 'note'}:${channel}:${data[1]}`, down: type === 176 ? data[2] >= 64 : type === 144 && data[2] > 0 }; }
function createGate() { const held = new Set(); return { accept(port, message) { const key = `${port}:${message.key}`; const was = held.has(key); if (message.down) held.add(key); else held.delete(key); return message.down && !was; }, reset() { held.clear(); } }; }
let adapter, access, learning = '', status = '', panelRefresh = () => {}, panicTimer;
const gate = createGate();
function receive(event) {
if (!adapter.settings().midiEnabled) return;
const message = decode(event.data); if (!message || !gate.accept(event.target?.id || '', message)) return;
const settings = adapter.settings();
if (learning) { const mappings = { ...(settings.midiMappings || {}) }; for (const [action, key] of Object.entries(mappings)) if (key === message.key) delete mappings[action]; mappings[learning] = message.key; settings.midiMappings = mappings; learning = ''; adapter.persist(); status = 'Mapping saved'; panelRefresh(); return; }
const action = Object.keys(actions).find(action => settings.midiMappings?.[action] === message.key); if (action) adapter.action(action);
}
function attach() { gate.reset(); for (const input of access.inputs.values()) input.onmidimessage = input.state === 'disconnected' ? null : receive; status = `${[...access.inputs.values()].filter(p => p.state !== 'disconnected').length} MIDI input(s) connected`; panelRefresh(); }
async function enable(on) {
adapter.settings().midiEnabled = on; adapter.persist(); learning = ''; gate.reset();
if (!on) { if (access) for (const input of access.inputs.values()) input.onmidimessage = null; status = 'MIDI is off'; panelRefresh(); return; }
try { access ||= await root.navigator.requestMIDIAccess({ sysex: false }); access.onstatechange = attach; attach(); }
catch (error) { adapter.settings().midiEnabled = false; adapter.persist(); status = `MIDI access unavailable: ${error.message}`; panelRefresh(); }
}
function fade({ volume, setVolume, pause, restore }) { clearInterval(panicTimer); const start = Date.now(); panicTimer = setInterval(() => { const fraction = Math.max(0, 1 - (Date.now() - start) / 1000); setVolume(volume * fraction); if (!fraction) { clearInterval(panicTimer); pause(); restore(); } }, 25); }
function configure(options) {
adapter = options;
root.SettingsSections.register({ id: 'midi', title: 'MIDI pedalboard', cluster: 'Live & worship', summary: () => adapter.settings().midiEnabled ? 'On · pedal mappings' : 'Off', render(container) {
const doc = container.ownerDocument;
if (!root.navigator.requestMIDIAccess) { const p = doc.createElement('p'); p.textContent = 'Web MIDI is unavailable in this browser. iPhone and iPad browsers do not support Web MIDI.'; container.append(p); return; }
const label = doc.createElement('label'); label.className = 'set-row'; label.textContent = 'Enable MIDI pedalboard'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = !!adapter.settings().midiEnabled; toggle.onchange = () => enable(toggle.checked); label.append(toggle);
const info = doc.createElement('p'); info.setAttribute('role', 'status'); const rows = doc.createElement('div'); container.append(label, info, rows);
const paint = () => { toggle.checked = !!adapter.settings().midiEnabled; info.textContent = learning ? `Press the pedal for ${actions[learning]}` : status || 'Enable MIDI to connect a pedalboard.'; rows.replaceChildren(); for (const [action, title] of Object.entries(actions)) { const row = doc.createElement('div'); row.className = 'download-job'; const text = doc.createElement('b'); text.textContent = `${title} · ${adapter.settings().midiMappings?.[action] || 'Unassigned'}`; const buttons = doc.createElement('div'); for (const name of ['Learn', 'Clear']) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = name; b.disabled = name === 'Learn' && !adapter.settings().midiEnabled; b.setAttribute('aria-label', `${name} MIDI ${title}`); b.onclick = () => { if (name === 'Learn') { learning = learning === action ? '' : action; gate.reset(); } else { delete adapter.settings().midiMappings?.[action]; adapter.persist(); } paint(); }; buttons.append(b); } row.append(text, buttons); rows.append(row); } };
panelRefresh = paint; paint(); return () => { if (panelRefresh === paint) panelRefresh = () => {}; learning = ''; };
} });
if (adapter.settings().midiEnabled && root.navigator.requestMIDIAccess) enable(true);
}
const api = { configure, decode, createGate, fade }; if (typeof module !== 'undefined') module.exports = api; else root.MidiPedals = api;
})(typeof window !== 'undefined' ? window : globalThis);

4
frontend/midi.test.js Normal file
View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict');
const { decode, createGate } = require('./midi');
test('MIDI note-off and zero velocity release notes; CC uses switch threshold', () => { assert.deepEqual(decode([0x92, 60, 80]), { key: 'note:2:60', down: true }); assert.equal(decode([0x82, 60, 64]).down, false); assert.equal(decode([0x92, 60, 0]).down, false); assert.equal(decode([0xb0, 64, 63]).down, false); assert.equal(decode([0xb0, 64, 64]).down, true); assert.equal(decode([0xf8]), null); });
test('held pedals trigger once, releases re-arm, different devices remain independent', () => { const gate = createGate(), down = decode([0xb0, 64, 127]), up = decode([0xb0, 64, 0]); assert.equal(gate.accept('one', down), true); assert.equal(gate.accept('one', down), false); assert.equal(gate.accept('two', down), true); gate.accept('one', up); assert.equal(gate.accept('one', down), true); gate.reset(); assert.equal(gate.accept('two', down), true); });

View File

@@ -0,0 +1,23 @@
/* Downloads uses the same quiet row rhythm as the surrounding library. */
.dl-empty-state { max-width:440px; margin:24px auto; padding:24px 16px; }
.dl-empty-state .dl-empty-icon-wrapper { width:auto; height:auto; border:0; border-radius:0; background:none; color:var(--text-2); margin-bottom:16px; box-shadow:none; }
.dl-empty-svg { width:32px; height:32px; }
.dl-empty-state .empty-title { font-size:20px; color:var(--text); }
.dl-empty-state .empty-desc, .dl-how-to-save, .dl-storage-note { color:var(--text-2); font-size:14px; line-height:1.5; }
.dl-how-to-save { margin:8px 0 16px; }
.dl-storage-note { margin-bottom:16px; font-variant-numeric:tabular-nums; }
.dl-saved-cta, .dl-saved-link, .dl-row .dl-act { min-height:44px; min-width:44px; padding:0 14px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text); font:inherit; cursor:pointer; }
.dl-saved-cta:hover, .dl-saved-link:hover, .dl-row .dl-act:hover { background:var(--bg-3); }
.dl-saved-cta:focus-visible, .dl-saved-link:focus-visible, .dl-row .dl-act:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
.dl-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; font-size:14px; color:var(--text-2); }
.dl-group { margin-bottom:20px; }
.dl-group-title { font-size:14px; font-weight:600; color:var(--text); margin:12px 0 6px; }
html[data-layout] .card.dl-row { display:grid; grid-template-columns:72px minmax(0,1fr); gap:12px; padding:12px 8px; }
html[data-layout] .dl-row .thumb { grid-column:1; grid-row:1; width:72px; align-self:start; margin-top:3px; }
html[data-layout] .dl-row .card-info { grid-column:2; grid-row:1; min-width:0; }
.dl-row .card-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dl-row .card-channel { overflow-wrap:anywhere; color:var(--text-2); }
.dl-row .dl-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.dl-row .dl-act.danger { color:var(--accent); background:transparent; }
.dl-row .dl-act:disabled { opacity:.5; cursor:wait; }
@media(min-width:900px) { html[data-layout] .card.dl-row { grid-template-columns:88px minmax(0,1fr); } html[data-layout] .dl-row .thumb { width:88px; } }

View File

@@ -156,10 +156,16 @@
resolve({ ok: false, fallback: true }); resolve({ ok: false, fallback: true });
return; return;
} }
let settled = false;
const abort = () => finish({ ok: false, paused: true });
const finish = (result) => { const finish = (result) => {
if (settled) return; settled = true;
opts.signal?.removeEventListener('abort', abort);
worker.terminate(); worker.terminate();
resolve(result); resolve(result);
}; };
if (opts.signal?.aborted) { abort(); return; }
opts.signal?.addEventListener('abort', abort, { once: true });
worker.onmessage = (e) => { worker.onmessage = (e) => {
const m = e.data || {}; const m = e.data || {};
if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 }); if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 });

23
frontend/party-dj.js Normal file
View File

@@ -0,0 +1,23 @@
(function(root) {
'use strict';
const ranked = entries => [...entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.length - a.votes.length);
function render(container, { state, host, pid, send, search }) {
const doc = container.ownerDocument;
if (host) { const label = doc.createElement('label'); label.className = 'pt-toggle'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = state.enabled; toggle.onchange = () => send({ type: 'dj', action: 'enable', enabled: toggle.checked }); label.append(toggle, ' Party DJ mode'); container.append(label); }
if (!state.enabled) return;
const heading = doc.createElement('h3'); heading.textContent = 'Party Queue'; container.append(heading);
const form = doc.createElement('div'); form.className = 'party-dj-search';
const input = doc.createElement('input'); input.type = 'search'; input.placeholder = 'Suggest a song'; input.setAttribute('aria-label', 'Search for a party suggestion');
const button = doc.createElement('button'); button.type = 'button'; button.textContent = 'Search'; const results = doc.createElement('div');
let request = 0;
button.onclick = async () => { const query = input.value.trim(); if (!query) return; const token = ++request; button.disabled = true; results.textContent = 'Searching…'; try { const videos = await search(query); if (!container.isConnected || token !== request) return; results.replaceChildren(); for (const video of videos.slice(0, 8)) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = `Suggest: ${video.title || video.id}`; b.onclick = () => send({ type: 'dj', action: 'suggest', video }); results.append(b); } if (!videos.length) results.textContent = 'No matches.'; } catch { results.textContent = 'Search unavailable. Try again.'; } finally { button.disabled = false; } };
input.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }; form.append(input, button); container.append(form, results);
for (const entry of ranked(state.entries)) {
const row = doc.createElement('div'); row.className = 'party-dj-row'; const title = doc.createElement('b'); title.textContent = entry.video.title; row.append(title);
const status = doc.createElement('small'); status.textContent = entry.approved ? 'Upcoming' : 'Awaiting host approval'; row.append(status);
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } container.append(row);
}
}
function register(open) { root.SettingsSections.register({ id: 'party-dj', title: 'Watch party', cluster: 'Live & worship', summary: () => 'Shared suggestions · host approval', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'Party DJ mode is off until the host enables it. Guests suggest songs and vote once per song; approved songs play in vote order.'; const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open watch party'; b.onclick = open; container.append(p, b); } }); }
const api = { ranked, render, register }; if (typeof module !== 'undefined') module.exports = api; else root.PartyDJ = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,3 @@
const { test } = require('node:test'), assert = require('node:assert/strict');
const { ranked } = require('./party-dj');
test('approved songs rank first, vote ties retain submission order', () => { const entries = [{ video: { id: 'a' }, approved: false, votes: ['a','b'] }, { video: { id: 'b' }, approved: true, votes: [] }, { video: { id: 'c' }, approved: true, votes: ['a'] }, { video: { id: 'd' }, approved: true, votes: [] }]; assert.deepEqual(ranked(entries).map(e => e.video.id), ['c','b','d','a']); assert.equal(entries[0].video.id, 'a'); });

59
frontend/piano-core.js Normal file
View File

@@ -0,0 +1,59 @@
/* Compact note sequences and practice ranges, shared by browser and server. */
(function (root) {
'use strict';
const MAX_NOTES = 30000;
function normalizeNotes(input) {
if (!Array.isArray(input) || input.length > MAX_NOTES)
throw new Error('Invalid or oversized note sequence');
return input
.map((note) => {
const pitch = Number(note.pitch),
start = Number(note.start),
end = Number(note.end),
velocity = Number(note.velocity);
if (
!Number.isInteger(pitch) ||
pitch < 21 ||
pitch > 108 ||
!Number.isFinite(start) ||
start < 0 ||
!Number.isFinite(end) ||
end <= start ||
end > 3600 ||
!Number.isFinite(velocity)
)
throw new Error('Invalid piano note');
return {
pitch,
start: Math.round(start * 1000) / 1000,
end: Math.max(Math.round(end * 1000) / 1000, Math.round(start * 1000) / 1000 + 0.001),
velocity: Math.max(1, Math.min(127, Math.round(velocity))),
hand: ['left', 'right'].includes(note.hand) ? note.hand : pitch < 60 ? 'left' : 'right',
};
})
.sort((a, b) => a.start - b.start || a.pitch - b.pitch);
}
function fromBasicPitch(notes) {
return normalizeNotes(
notes
.filter((n) => n.pitchMidi >= 21 && n.pitchMidi <= 108)
.map((n) => ({
pitch: n.pitchMidi,
start: n.startTimeSeconds,
end: n.startTimeSeconds + n.durationSeconds,
velocity: n.amplitude * 127,
})),
);
}
const activeNotes = (notes, time) => notes.filter((n) => n.start <= time && n.end > time);
function practiceRange(time, duration) {
const start = Math.max(0, Number(time) || 0),
end = Math.min(start + 8, Number(duration) || start + 8);
return end > start
? { start: Math.round(start * 1000) / 1000, end: Math.round(end * 1000) / 1000 }
: null;
}
const api = { normalizeNotes, fromBasicPitch, activeNotes, practiceRange, MAX_NOTES };
if (typeof module !== 'undefined') module.exports = api;
else root.PianoCore = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,50 @@
const { test } = require('node:test'),
assert = require('node:assert/strict');
const P = require('./piano-core');
test('compact notes sort, round timing and infer hands below middle C', () => {
const notes = P.normalizeNotes([
{ pitch: 60, start: 1.23456, end: 1.8, velocity: 85 },
{ pitch: 48, start: 0, end: 0.5, velocity: 150 },
{ pitch: 55, start: 2, end: 3, velocity: 40, hand: 'right' },
]);
assert.equal(notes[0].hand, 'left');
assert.equal(notes[0].velocity, 127);
assert.equal(notes[1].start, 1.235);
assert.equal(notes[1].hand, 'right');
assert.equal(notes[2].hand, 'right');
});
test('invalid notes are rejected and tiny notes retain positive duration', () => {
assert.throws(() => P.normalizeNotes([{ pitch: 200, start: 0, end: 1, velocity: 90 }]));
assert.throws(() => P.normalizeNotes([{ pitch: 60, start: 2, end: 1, velocity: 90 }]));
const [note] = P.normalizeNotes([{ pitch: 60, start: 0.0001, end: 0.0002, velocity: 0 }]);
assert.ok(note.end > note.start);
});
test('Basic Pitch output converts amplitude to MIDI velocity and active notes use exclusive ends', () => {
const notes = P.fromBasicPitch([
{ pitchMidi: 60, startTimeSeconds: 1, durationSeconds: 0.5, amplitude: 0.5 },
]);
assert.equal(notes[0].velocity, 64);
assert.equal(P.activeNotes(notes, 1).length, 1);
assert.equal(P.activeNotes(notes, 1.5).length, 0);
assert.deepEqual(P.practiceRange(95, 100), { start: 95, end: 100 });
assert.equal(P.practiceRange(100, 100), null);
});
test('browser engine prefers WebGPU and reliably falls back to CPU', async () => {
const { selectBackend } = await import('./piano-engine.mjs');
const calls = [];
const tf = {
setBackend: async (name) => {
calls.push(name);
return true;
},
};
assert.equal(await selectBackend(tf, true, async () => {}), 'webgpu');
assert.deepEqual(calls, ['webgpu']);
const fallback = { setBackend: async (name) => name === 'cpu' };
assert.equal(
await selectBackend(fallback, true, async () => {
throw new Error('Unavailable GPU');
}),
'cpu',
);
});

72
frontend/piano-engine.mjs Normal file
View File

@@ -0,0 +1,72 @@
// This module, TensorFlow and the model are imported only after Transcribe.
let enginePromise, model;
const MODEL = 'https://cdn.jsdelivr.net/npm/@spotify/basic-pitch@1.0.1/model/model.json';
async function engine() {
if (!enginePromise)
enginePromise = (async () => {
const tf = await import('https://esm.sh/@tensorflow/tfjs@4.22.0');
await selectBackend(
tf,
!!navigator.gpu,
() => import('https://esm.sh/@tensorflow/tfjs-backend-webgpu@4.22.0'),
);
await tf.ready();
const pitch = await import(
'https://esm.sh/@spotify/basic-pitch@1.0.1?deps=@tensorflow/tfjs@4.22.0'
);
const graph = await tf.loadGraphModel(MODEL);
model = new pitch.BasicPitch(Promise.resolve(graph));
return { tf, pitch, backend: tf.getBackend() };
})().catch((error) => {
enginePromise = null;
throw error;
});
return enginePromise;
}
export async function selectBackend(tf, hasGPU, loadGPU) {
if (hasGPU) {
try {
await loadGPU();
if (await tf.setBackend('webgpu')) return 'webgpu';
} catch {}
}
for (const backend of ['webgl', 'cpu']) {
try {
if (await tf.setBackend(backend)) return backend;
} catch {}
}
throw new Error('No TensorFlow backend is available in this browser.');
}
export async function transcribe(buffer, progress) {
const decoded = await new OfflineAudioContext(1, 1, 22050).decodeAudioData(buffer);
if (decoded.duration > 900) throw new Error('Browser transcription is limited to 15 minutes.');
const context = new OfflineAudioContext(1, Math.ceil(decoded.duration * 22050), 22050),
source = context.createBufferSource();
source.buffer = decoded;
source.connect(context.destination);
source.start();
const audio = await context.startRendering();
progress('Loading Basic Pitch library and model…');
const { tf, pitch, backend } = await engine();
const frames = [],
onsets = [];
// Basic Pitch 1.0.1 retains intermediate tensors; release each run's scope.
// Model weights were loaded before this scope and stay available for reuse.
tf.engine().startScope();
try {
await model.evaluateModel(
audio.getChannelData(0),
(f, o) => {
frames.push(...f);
onsets.push(...o);
},
(p) => progress(`Transcribing on ${backend}: ${Math.round(p * 100)}%`),
);
} finally {
tf.engine().endScope();
}
return {
notes: pitch.noteFramesToTime(pitch.outputToNotesPoly(frames, onsets, 0.25, 0.25, 5)),
engine: `basic-pitch-1.0.1/${backend}`,
};
}

347
frontend/piano.js Normal file
View File

@@ -0,0 +1,347 @@
/* Opt-in transcription, cached sequences, synced keyboard and practice queue. */
(function (root) {
'use strict';
let adapter,
database,
busy = false;
const memory = new Map();
async function db() {
if (!database)
database = new Promise((resolve, reject) => {
const req = root.indexedDB.open('ytp-piano', 1);
req.onupgradeneeded = () => req.result.createObjectStore('notes');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return database;
}
async function cache(id, value) {
try {
const store = (await db())
.transaction('notes', value ? 'readwrite' : 'readonly')
.objectStore('notes');
if (!value)
return await new Promise((resolve, reject) => {
const req = store.get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const transaction = store.transaction;
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
store.put(value, id);
});
return value;
} catch {
if (value) memory.set(id, value);
return memory.get(id) || null;
}
}
async function audio(id, file) {
if (file) {
if (file.size > 100 * 1048576) throw new Error('Choose a file smaller than 100 MB.');
return file.arrayBuffer();
}
const local = await root.OPFS?.getFileObject(id);
if (local) {
if (local.size > 100 * 1048576) throw new Error('Use a smaller audio file (100 MB maximum).');
return local.arrayBuffer();
}
const response = await fetch(
id.startsWith('upl_')
? `/api/uploads/${encodeURIComponent(id)}`
: `/api/media/${encodeURIComponent(id)}?a=1`,
);
if (!response.ok)
throw new Error('Save the audio on this device/server, or choose an audio file.');
const reader = response.body.getReader(),
chunks = [];
let size = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
size += value.length;
if (size > 100 * 1048576) throw new Error('Audio exceeds 100 MB.');
chunks.push(value);
}
} finally {
await reader.cancel().catch(() => {});
}
const out = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out.buffer;
}
function keyboard(canvas, notes, time) {
const ctx = canvas.getContext('2d'),
ratio = root.devicePixelRatio || 1,
width = canvas.clientWidth || 390;
if (canvas.width !== Math.round(width * ratio)) {
canvas.width = Math.round(width * ratio);
canvas.height = 150 * ratio;
}
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, width, 150);
const active = new Map(root.PianoCore.activeNotes(notes, time).map((n) => [n.pitch, n.hand])),
black = (p) => [1, 3, 6, 8, 10].includes(p % 12),
whites = [];
for (let p = 21; p <= 108; p++) if (!black(p)) whites.push(p);
const unit = width / whites.length;
whites.forEach((p, i) => {
ctx.fillStyle = active.has(p)
? active.get(p) === 'left'
? '#62c9e8'
: '#ffb65c'
: '#f4f4f2';
ctx.fillRect(i * unit, 0, unit - 1, 145);
});
for (let p = 22; p < 108; p++)
if (black(p)) {
const left = whites.filter((n) => n < p).length * unit;
ctx.fillStyle = active.has(p)
? active.get(p) === 'left'
? '#62c9e8'
: '#ffb65c'
: '#141619';
ctx.fillRect(left - unit * 0.32, 0, unit * 0.64, 90);
}
}
function configure(options) {
adapter = options;
root.SettingsSections.register({
id: 'piano',
title: 'Piano transcription',
cluster: 'Live & worship',
summary: () =>
adapter.settings().pianoEnabled
? 'On · browser or optional worker'
: 'Off · load model on request',
render(container) {
const doc = container.ownerDocument,
toggle = doc.createElement('input');
toggle.type = 'checkbox';
toggle.checked = !!adapter.settings().pianoEnabled;
const label = doc.createElement('label');
label.className = 'set-row';
label.textContent = 'Enable piano transcription';
label.append(toggle);
const note = doc.createElement('p');
note.textContent =
'Basic Pitch runs on this device; its library/model download only when you transcribe. Prefers WebGPU, then WebGL/CPU. Best for isolated instruments. The optional server worker must be enabled by an administrator.';
const file = doc.createElement('input');
file.type = 'file';
file.accept = 'audio/*,video/*';
file.setAttribute('aria-label', 'Optional audio file for the playing song');
const token = doc.createElement('input');
token.type = 'password';
token.autocomplete = 'off';
token.placeholder = 'Admin API token (server requests only; not saved)';
token.setAttribute('aria-label', 'Admin API token for piano worker');
const status = doc.createElement('p');
status.setAttribute('role', 'status');
const actions = doc.createElement('div');
actions.className = 'piano-actions';
const canvas = doc.createElement('canvas');
canvas.className = 'piano-keyboard';
canvas.setAttribute('role', 'img');
canvas.setAttribute(
'aria-label',
'88-key piano: left-hand notes blue, right-hand notes amber',
);
const practice = doc.createElement('div');
practice.className = 'piano-practice';
container.append(label, note, file, token, actions, status, canvas, practice);
let sequence = [],
currentId = '',
lastTime = -1,
alive = true;
const buttons = [];
const snapshot = () => adapter.state();
function enabled() {
for (const b of buttons) b.disabled = busy || !toggle.checked;
}
toggle.onchange = () => {
adapter.settings().pianoEnabled = toggle.checked;
adapter.persist();
enabled();
};
function button(text, action) {
const b = doc.createElement('button');
b.type = 'button';
b.textContent = text;
b.onclick = action;
actions.append(b);
buttons.push(b);
return b;
}
async function run(action) {
const id = snapshot().id;
if (!id) {
status.textContent = 'Play a song first.';
return;
}
if (busy) return;
busy = true;
enabled();
try {
await action(id);
} catch (error) {
if (alive) status.textContent = error.message;
} finally {
busy = false;
enabled();
}
}
button('Transcribe in browser', () =>
run(async (id) => {
const buffer = await audio(id, file.files[0]);
const engine = await import('./piano-engine.mjs');
const result = await engine.transcribe(buffer, (text) => {
if (alive) status.textContent = text;
});
const notes = root.PianoCore.fromBasicPitch(result.notes);
await cache(id, { notes, engine: result.engine, at: Date.now() });
if (snapshot().id === id) {
sequence = notes;
status.textContent = `${notes.length} notes cached on this device · ${result.engine}. Hand split is a middle-C heuristic.`;
}
}),
);
button('Request server transcription', () =>
run(async (id) => {
const response = await fetch(`/api/media/${encodeURIComponent(id)}/piano`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Server request failed');
status.textContent = `Server job ${result.job.status}. Use “Load server result” when ready.`;
}),
);
button('Load server result', () =>
run(async (id) => {
const response = await fetch(`/api/media/${encodeURIComponent(id)}/piano`),
result = await response.json();
if (!response.ok) throw new Error(result.error || 'Server unavailable');
if (!result.job?.notes) {
status.textContent = result.enabled
? `Server job: ${result.job?.status || 'not requested'}`
: 'The optional server worker is off.';
return;
}
const notes = root.PianoCore.normalizeNotes(result.job.notes);
await cache(id, { notes, engine: 'bytedance-server', at: Date.now() });
if (snapshot().id === id) {
sequence = notes;
status.textContent = `${notes.length} server notes cached on this device.`;
}
}),
);
button('Export note JSON', () => {
if (!sequence.length) {
status.textContent = 'No note sequence yet.';
return;
}
const url = URL.createObjectURL(
new Blob([JSON.stringify(sequence)], { type: 'application/json' }),
);
const a = doc.createElement('a');
a.href = url;
a.download = `${snapshot().id}.notes.json`;
doc.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
function ranges() {
return adapter.settings().pianoPractice?.[snapshot().id] || [];
}
function saveRanges(value) {
const settings = adapter.settings();
settings.pianoPractice ||= {};
settings.pianoPractice[snapshot().id] = value;
adapter.persist();
renderPractice();
}
function renderPractice() {
practice.replaceChildren();
const h = doc.createElement('h4');
h.textContent = 'Practice queue';
practice.append(h);
ranges().forEach((range, i) => {
const row = doc.createElement('div');
row.className = 'piano-actions';
const play = doc.createElement('button');
play.type = 'button';
play.textContent = `Loop ${range.start.toFixed(1)}–${range.end.toFixed(1)} s`;
play.onclick = () => adapter.loop(range.start, range.end);
const remove = doc.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', `Remove practice range ${i + 1}`);
remove.onclick = () => saveRanges(ranges().filter((_, k) => k !== i));
row.append(play, remove);
practice.append(row);
});
}
button('Add current 8 s to practice', () => {
if (!snapshot().id) return;
const range = root.PianoCore.practiceRange(snapshot().time, snapshot().duration);
if (range && ranges().length < 50) saveRanges([...ranges(), range]);
});
button('Stop practice loop', () => adapter.clearLoop());
enabled();
let frame, idleTimer;
async function tick() {
if (!alive) return;
if (!container.isConnected) {
alive = false;
token.value = '';
return;
}
if (container.hidden) {
idleTimer = setTimeout(() => {
frame = root.requestAnimationFrame(tick);
}, 250);
return;
}
enabled();
const state = snapshot();
if (state.id !== currentId) {
currentId = state.id;
sequence = [];
renderPractice();
const cached = state.id ? await cache(state.id) : null;
if (!alive) return;
if (currentId === state.id) {
sequence = cached?.notes || [];
status.textContent = cached
? `${sequence.length} cached notes · ${cached.engine}`
: 'Choose a playing song, then transcribe.';
}
}
if (state.time !== lastTime || state.paused) {
keyboard(canvas, sequence, state.time);
lastTime = state.time;
}
frame = root.requestAnimationFrame(tick);
}
frame = root.requestAnimationFrame(tick);
return () => {
alive = false;
clearTimeout(idleTimer);
root.cancelAnimationFrame(frame);
token.value = '';
};
},
});
}
const api = { configure };
if (typeof module !== 'undefined') module.exports = api;
else root.Piano = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -598,3 +598,4 @@
outline: none; overflow-wrap: anywhere; transition: border-color 0.16s, box-shadow 0.16s; outline: none; overflow-wrap: anywhere; transition: border-color 0.16s, box-shadow 0.16s;
} }
.modal textarea.rename-input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,75,50,0.12); } .modal textarea.rename-input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,75,50,0.12); }
.export-bar[hidden] { display: none; }

16
frontend/saved-page.css Normal file
View File

@@ -0,0 +1,16 @@
.saved-summary.saved-storage-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0 16px; }
.saved-storage-text { min-width:0; color:var(--text-2); font-size:14px; font-variant-numeric:tabular-nums; }
.saved-clear { flex:none; min-height:44px; padding:0 10px; border:0; background:transparent; color:var(--accent); font:inherit; cursor:pointer; }
html[data-layout] .card.saved-card { display:grid; grid-template-columns:72px minmax(0,1fr) 96px; align-items:center; gap:12px; min-height:88px; }
html[data-layout] .saved-card .thumb { grid-column:1; grid-row:1; width:72px; }
html[data-layout] .saved-card .card-info { grid-column:2; grid-row:1; min-width:0; }
html[data-layout] .saved-card .card-title { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; overflow-wrap:anywhere; }
html[data-layout] .saved-file-actions { grid-column:3; grid-row:1; display:flex; align-items:center; gap:8px; }
.saved-file-action { width:44px; height:44px; min-width:44px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-sm,8px); background:var(--bg-2); color:var(--text-2); cursor:pointer; }
.saved-file-action svg { display:block; width:22px; height:22px; }
.saved-file-action.danger { color:var(--accent); background:transparent; }
.saved-file-action:hover, .saved-clear:hover { background:var(--bg-3); color:var(--text); }
.saved-file-action.danger:hover, .saved-clear:hover { color:var(--accent); }
.saved-file-action:focus-visible, .saved-clear:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
.saved-file-action:disabled, .saved-clear:disabled { opacity:.5; cursor:wait; }
@media(min-width:900px) { html[data-layout] .card.saved-card { grid-template-columns:88px minmax(0,1fr) 96px; } html[data-layout] .saved-card .thumb { width:88px; } }

25
frontend/saved-page.js Normal file
View File

@@ -0,0 +1,25 @@
/* Saved-file rows share one accessible action area in both layouts. */
(function(root) {
'use strict';
const matches = (video, query) => !query || `${video.title || video.id} ${video.channel || ''}`.toLowerCase().includes(query.toLowerCase());
const paths = {
export: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
delete: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>'
};
function button(doc, kind, label, action) {
const b = doc.createElement('button'); b.type = 'button';
b.className = `saved-file-action${kind === 'delete' ? ' danger' : ''}`;
b.setAttribute('aria-label', label); b.title = label;
b.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
b.onclick = e => { e.stopPropagation(); action(); }; return b;
}
function actions(row, video, exportFile, deleteFile) {
const doc = row.ownerDocument, group = doc.createElement('div');
group.className = 'saved-file-actions'; group.setAttribute('role', 'group');
group.setAttribute('aria-label', `File actions for ${video.title || video.id}`);
group.append(button(doc, 'export', `Save to this device: ${video.title || video.id}`, exportFile), button(doc, 'delete', `Delete saved file: ${video.title || video.id}`, deleteFile));
row.append(group);
}
const api = { matches, actions };
if (typeof module !== 'undefined') module.exports = api; else root.SavedPage = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { matches } = require('./saved-page.js');
test('saved filtering matches title and channel without changing the library', () => {
const video = { id: 'one', title: 'A quiet morning', channel: 'Studio sessions' };
assert.equal(matches(video, 'QUIET'), true);
assert.equal(matches(video, 'studio'), true);
assert.equal(matches(video, 'evening'), false);
assert.equal(matches({ id: 'unknown' }, 'unknown'), true);
assert.equal(matches(video, ''), true);
});

View File

@@ -0,0 +1,34 @@
/* Best-effort discovery for playback that never requests server streams. */
(function(root) {
'use strict';
function createReporter({ fetch: send = root.fetch?.bind(root), online = () => root.navigator?.onLine !== false, enabled = () => true } = {}) {
const seen = new Set(), deferred = new Map();
function report(meta) {
if (!enabled() || !send || !meta || !/^[A-Za-z0-9_-]{11}$/.test(meta.id) || meta.custom || meta.upload || !meta.title || meta.title === meta.id || seen.has(meta.id)) return;
// Snapshot only the known metadata, never stream URLs or local blob art.
let thumbnail;
try {
const url = new URL(meta.thumbnail);
if (url.protocol === 'https:' && /^(?:i|i\d)\.ytimg\.com$/.test(url.hostname) && !url.port && !url.username && !url.password) thumbnail = url.href;
} catch { /* server supplies canonical art */ }
const card = { id: meta.id, title: String(meta.title).slice(0,300), channel: String(meta.channel || meta.artist || meta.uploader || '').slice(0,300),
duration: Math.max(0, Math.min(86400, Number(meta.duration) || 0)), ...(thumbnail ? { thumbnail } : {}) };
if (!online()) {
deferred.set(meta.id, card);
if (deferred.size > 256) deferred.delete(deferred.keys().next().value);
return;
}
seen.add(meta.id); deferred.delete(meta.id);
// No synchronous/network failure can interrupt the player event handler.
try {
Promise.resolve(send(`/api/media/${encodeURIComponent(card.id)}/meta`, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(card),
signal: root.AbortSignal?.timeout?.(10000),
})).catch(() => {});
} catch { /* best effort; a later session can report again */ }
}
return { report, flush: () => { if (online()) for (const card of [...deferred.values()]) report(card); } };
}
if (typeof module !== 'undefined') module.exports = { createReporter };
else { const reporter = createReporter(); root.ServerBackfill = reporter; root.addEventListener('online', reporter.flush); }
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,26 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createReporter } = require('./server-backfill.js');
const meta={id:'0gfX0dFLaBc',title:'Because You are God',artist:'Cathedral of Praise Worship',duration:240,thumbnail:'blob:device-art'};
test('first playback reports known metadata once per video per session',()=>{
const requests=[];const reporter=createReporter({fetch:(url,options)=>{requests.push({url,options});return Promise.resolve({ok:true});}});
reporter.report(meta);reporter.report(meta);reporter.report({...meta,id:'bbbbbbbbbbb'});
assert.equal(requests.length,2);assert.equal(requests[0].url,'/api/media/0gfX0dFLaBc/meta');
const card=JSON.parse(requests[0].options.body);assert.equal(card.channel,meta.artist);assert.equal(card.duration,240);assert.equal(card.thumbnail,undefined);
assert.equal(requests[0].options.method,'POST');
});
test('offline plays defer until online and repeated events do not multiply reports',()=>{
let online=false;const requests=[];const reporter=createReporter({online:()=>online,fetch:(...args)=>{requests.push(args);return Promise.resolve();}});
reporter.report(meta);reporter.report(meta);reporter.flush();assert.equal(requests.length,0);
online=true;reporter.flush();reporter.flush();reporter.report(meta);assert.equal(requests.length,1);
});
test('uploads, edits, unknown titles and disabled native shells never report',()=>{
const requests=[];const fetch=(...args)=>{requests.push(args);};const reporter=createReporter({fetch});
for(const item of [{...meta,id:'upl_123'},{...meta,custom:true},{...meta,upload:true},{...meta,title:meta.id},{...meta,title:''}])reporter.report(item);
createReporter({fetch,enabled:()=>false}).report(meta);assert.equal(requests.length,0);
});
test('synchronous and rejected network errors are isolated from playback',async()=>{
assert.doesNotThrow(()=>createReporter({fetch:()=>{throw new Error('offline');}}).report(meta));
assert.doesNotThrow(()=>createReporter({fetch:()=>Promise.reject(new Error('offline'))}).report(meta));
await new Promise(resolve=>setImmediate(resolve));
});

View File

@@ -0,0 +1,49 @@
/* Planning Center CSV, pasted setlists and ChordPro/SongSelect text imports. */
(function(root) {
'use strict';
function csv(text) { const rows = []; let row = [], field = '', quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (c === '"') { if (quoted && text[i + 1] === '"') { field += '"'; i++; } else quoted = !quoted; } else if (!quoted && (c === ',' || c === '\n')) { row.push(field.trim()); field = ''; if (c === '\n') { rows.push(row); row = []; } } else if (c !== '\r') field += c; } row.push(field.trim()); if (row.some(Boolean)) rows.push(row); return rows; }
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function score(title, candidate) { const a = normalize(title), b = normalize(candidate); if (a && a === b) return 1; const x = new Set(a.split(' ').filter(Boolean)), y = new Set(b.split(' ').filter(Boolean)); const overlap = [...x].filter(word => y.has(word)).length; return overlap / Math.max(1, new Set([...x, ...y]).size); }
function matches(title, library) { return [...new Map(library.filter(v => v?.id).map(v => [v.id, v])).values()].map(video => ({ video, score: score(title, video.title) })).filter(v => v.score >= .45).sort((a,b) => b.score - a.score).slice(0, 8).map(v => v.video); }
function parse(text) {
text = String(text || '').replace(/^\uFEFF/, '').trim(); if (!text) return [];
const rows = csv(text), header = rows[0].map(normalize), titleCol = header.findIndex(v => ['title','song','song title','name'].includes(v));
if (titleCol >= 0 && rows[0].length > 1) { const keyCol = header.indexOf('key'), orderCol = header.indexOf('order'); return rows.slice(1).filter(row => row[titleCol]).map((row,i) => ({ title: row[titleCol], key: row[keyCol] || '', order: Number(row[orderCol]) || i + 1 })).sort((a,b) => a.order - b.order); }
if (/\{(?:title|t):|^Title\s*:/im.test(text)) {
const blocks = text.split(/(?=^\s*(?:\{(?:title|t):|Title\s*:))/im).filter(v => v.trim());
return blocks.map((block,i) => { const title = block.match(/\{(?:title|t):\s*([^}]+)\}|^Title\s*:\s*(.+)$/im), key = block.match(/\{key:\s*([^}]+)\}|^Key\s*:\s*(.+)$/im); return { title: (title?.[1] || title?.[2] || '').trim(), key: (key?.[1] || key?.[2] || '').trim(), order: i + 1, chordPro: block.trim() }; }).filter(v => v.title);
}
return text.split(/\r?\n/).filter(v => v.trim()).map((line,i) => { const key = line.match(/\s*(?:\[([A-G][#b]?(?:m|maj|min)?)\]|\|\s*([A-G][#b]?(?:m|maj|min)?))\s*$/); return { title: line.replace(/^\s*\d+[.)]\s*/, '').replace(key?.[0] || /$^/, '').trim(), key: key?.[1] || key?.[2] || '', order: i + 1 }; }).filter(v => v.title);
}
let adapter;
function open() {
const doc = root.document, body = doc.createElement('div'); body.className = 'setlist-import';
const name = doc.createElement('input'); name.placeholder = 'Setlist name'; name.value = 'Imported setlist'; name.setAttribute('aria-label', 'Imported setlist name');
const text = doc.createElement('textarea'); text.rows = 8; text.placeholder = 'Paste title, key, order CSV; a song per line; or ChordPro / SongSelect text'; text.setAttribute('aria-label', 'Setlist text');
const file = doc.createElement('input'); file.type = 'file'; file.accept = '.txt,.csv,.cho,.chopro,.pro,.chordpro,text/plain,text/csv'; file.setAttribute('aria-label', 'Choose a setlist text file'); file.onchange = async () => { if (file.files[0]) { if (file.files[0].size > 1048576) { status.textContent = 'Choose a text file smaller than 1 MB.'; return; } text.value = await file.files[0].text(); } };
const status = doc.createElement('p'); status.setAttribute('role', 'status'); const results = doc.createElement('div'); let selections = [], busy = false;
body.append(name, text, file, status, results);
async function review() {
if (busy) return; const entries = parse(text.value); if (!entries.length) { status.textContent = 'No songs found.'; return; } if (entries.length > 100) { status.textContent = 'Import at most 100 songs at a time.'; return; }
busy = true; status.textContent = 'Finding matches…'; results.replaceChildren(); selections = [];
try {
const library = await adapter.library();
for (const entry of entries) {
if (!body.isConnected) return; const row = doc.createElement('div'); row.className = 'download-job'; const title = doc.createElement('b'); title.textContent = `${entry.order}. ${entry.title}${entry.key ? ` · Key ${entry.key}` : ''}`;
const select = doc.createElement('select'); select.setAttribute('aria-label', `Match ${entry.title}`); let candidates = matches(entry.title, library);
if (!candidates.length) { try { candidates = await adapter.search(entry.title); } catch {} }
const skip = doc.createElement('option'); skip.value = ''; skip.textContent = 'Skip this song'; select.append(skip);
function append(videos) { for (const video of videos.slice(0, 10)) if (!candidates.some(v => v.id === video.id) || ![...select.options].some(o => o.value === video.id)) { const option = doc.createElement('option'); option.value = video.id; option.textContent = `${video.title || video.id} · ${video.channel || ''}`; select.append(option); if (!candidates.some(v => v.id === video.id)) candidates.push(video); } }
append(candidates); if (candidates[0] && score(entry.title, candidates[0].title) === 1) select.value = candidates[0].id;
const searchInput = doc.createElement('input'); searchInput.value = entry.title; searchInput.setAttribute('aria-label', `Search match for ${entry.title}`); const search = doc.createElement('button'); search.type = 'button'; search.textContent = 'Search YouTube'; search.onclick = async () => { search.disabled = true; try { append(await adapter.search(searchInput.value.trim())); } catch { status.textContent = 'Search failed. Try again.'; } finally { search.disabled = false; } };
selections.push(() => { const video = candidates.find(v => v.id === select.value); return video ? { ...video, tags: [...new Set([...(video.tags || []), ...(entry.key ? [`Key ${entry.key}`] : [])])], key: entry.key, ...(entry.chordPro ? { chordPro: entry.chordPro } : {}) } : null; });
row.append(title, select, searchInput, search); results.append(row);
}
status.textContent = 'Review each match, then create the setlist. Unselected songs are skipped.';
} catch { status.textContent = 'Could not load the library.'; } finally { busy = false; }
}
adapter.modal('Import setlist', body, [{ label: 'Cancel', onClick: adapter.close }, { label: 'Find matches', onClick: review }, { label: 'Create setlist', primary: true, onClick: () => { if (busy) return; const videos = selections.map(select => select()).filter(Boolean); if (!videos.length) { status.textContent = 'Choose at least one song first.'; return; } adapter.create(name.value.trim() || 'Imported setlist', videos); adapter.close(); } }]);
}
function configure(options) { adapter = options; const anchor = root.document.getElementById('homePlNewBtn'); if (anchor) { const b = root.document.createElement('button'); b.type = 'button'; b.className = 'home-pl-new'; b.textContent = 'Import setlist'; b.onclick = open; anchor.after(b); } root.SettingsSections.register({ id: 'setlist-import', title: 'Setlist import', cluster: 'Library & storage', summary: () => 'Planning Center · SongSelect · ChordPro', render(container) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Import setlist'; b.onclick = open; container.append(b); } }); }
const api = { parse, csv, matches, score, configure, open }; if (typeof module !== 'undefined') module.exports = api; else root.SetlistImport = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { parse, matches, csv } = require('./setlist-import');
test('Planning Center CSV supports quoted titles and order', () => { assert.deepEqual(parse('Title,Key,Order\n"Grace, Amazing",E,2\nFirst,G,1').map(v => [v.title,v.key]), [['First','G'],['Grace, Amazing','E']]); assert.deepEqual(csv('Title,Key\n"A ""quoted"" song",D')[1], ['A "quoted" song','D']); });
test('ChordPro and SongSelect preserve chord text and key', () => { const [a,b] = parse('{title: Grace}\n{key: E}\n[E]Amazing [A]grace\n{title: Mercy}\n{key: D}\n[D]Mercy'); assert.equal(a.title,'Grace'); assert.equal(a.key,'E'); assert.match(a.chordPro, /\[A\]grace/); assert.equal(b.title,'Mercy'); assert.equal(parse('Title: Song\nKey: Bb\nWords')[0].key,'Bb'); });
test('pasted setlists strip numbering and attach key tags; matching deduplicates library', () => { assert.equal(parse('1. Song [E]\n2. Mercy | F#')[1].key,'F#'); assert.deepEqual(matches('Amazing Grace', [{id:'a',title:'Amazing Grace'}, {id:'a',title:'Amazing Grace'}, {id:'b',title:'Other'}]).map(v => v.id), ['a']); });

View File

@@ -0,0 +1,46 @@
.settings-browser { display: block; width: 100%; min-width: 0; }
.settings-browser [hidden] { display: none !important; }
.settings-search { margin-bottom: 12px; }
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
.settings-overview h3 { color: var(--text-dim); margin: 16px 0 4px; font: 600 12px var(--ui, sans-serif); }
.settings-section-row { width: 100%; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px; background: transparent; color: var(--text); border: 0; border-bottom: 1px solid var(--line-soft); text-align: left; cursor: pointer; font: inherit; }
.settings-section-row svg { width: 20px; height: 20px; flex: none; color: var(--accent-bright); }
.settings-section-row span { flex: 1; min-width: 0; }
.settings-section-row b { font-size: 14px; font-weight: 600; }
.settings-section-row small { display: block; color: var(--text-2); font-size: 12px; line-height: 1.4; margin-top: 2px; }
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
.settings-browser .set-row { min-height: 44px; }
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
.settings-browser .set-row > span { min-width: 0; }
.settings-browser .set-row input[type="range"] { max-width: 42%; }
.settings-browser .settings-detail { display: none; min-width: 0; }
.settings-browser[data-detail="1"] .settings-overview { display: none; }
.settings-browser[data-detail="1"] .settings-detail { display: block; }
@media (min-width: 900px) {
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
.settings-back { display: none; }
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
}
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
.download-job b { overflow-wrap: anywhere; }
.download-job small, .download-storage p { color: var(--text-2); }
.download-job progress { width: 100%; accent-color: var(--accent); }
.download-job > div { display: flex; gap: 8px; }
.download-job button { min-height: 44px; padding: 8px 16px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; }
.party-dj-search { display: flex; gap: 8px; margin-block: 12px; }
.party-dj-search input { min-width: 0; flex: 1; }
.party-dj-row { padding-block: 12px; border-top: 1px solid var(--line); }
.party-dj-row b, .party-dj-row small { display: block; overflow-wrap: anywhere; }
.party-dj-row small { color: var(--text-2); margin-block: 6px; }
.party-dj-row button, .party-dj-search button, .party-dj-search + div button { min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin: 4px; }
.setlist-import { display: grid; gap: 12px; }
.setlist-import input, .setlist-import textarea, .setlist-import select { width: 100%; min-width: 0; min-height: 44px; padding: 10px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; }
.setlist-import .download-job > button { min-height: 44px; }
.piano-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-block: 12px; }
.piano-actions button { min-height: 44px; padding: 8px 12px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; }
.piano-keyboard { width: 100%; height: 150px; display: block; }
#settings-section-piano input[type="file"], #settings-section-piano input[type="password"] { max-width: 100%; min-height: 44px; }

View File

@@ -0,0 +1,104 @@
/* Settings navigation and extension registry. All controls stay mounted. */
(function (root) {
'use strict';
const clusters = ['Listening', 'Library & storage', 'Live & worship', 'Account & sync', 'Advanced & about'];
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function search(entries, query) {
const words = normalize(query).split(' ').filter(Boolean);
return entries.filter(entry => words.every(word => normalize(`${entry.title} ${entry.description || ''}`).includes(word)));
}
function createRegistry() {
const entries = new Map();
return {
register(section) {
if (!section || !/^[a-z][a-z0-9-]*$/.test(section.id) || !section.title || typeof section.render !== 'function') throw new TypeError('A settings section needs an id, title and render function.');
entries.set(section.id, { cluster: 'Advanced & about', summary: () => '', ...section });
return () => { if (entries.get(section.id)?.render === section.render) entries.delete(section.id); };
},
list() { return [...entries.values()]; },
get(id) { return entries.get(id); },
};
}
const registry = createRegistry();
let dispose = () => {};
const icon = paths => `<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths || '<circle cx="12" cy="12" r="7"/>'}</svg>`;
function mount(host, context = {}) {
dispose();
const document = host.ownerDocument, win = document.defaultView, sections = registry.list(), panels = new Map(), rows = new Map(), cleanups = [];
let remembered = ''; try { remembered = win.sessionStorage.getItem('ytpSettingsSection') || ''; } catch {}
let active = registry.get(remembered) ? remembered : sections[0]?.id;
host.classList.add('settings-browser');
const toolbar = document.createElement('div'); toolbar.className = 'settings-search';
const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'Search settings'; input.setAttribute('aria-label', 'Search settings and controls'); toolbar.append(input);
const grid = document.createElement('div'); grid.className = 'settings-grid';
const navigation = document.createElement('nav'); navigation.className = 'settings-overview'; navigation.setAttribute('aria-label', 'Settings sections');
const detail = document.createElement('div'); detail.className = 'settings-detail';
const back = document.createElement('button'); back.type = 'button'; back.className = 'settings-back'; back.textContent = '← All settings'; detail.append(back);
const results = document.createElement('div'); results.className = 'settings-results'; results.hidden = true;
const empty = document.createElement('p'); empty.className = 'settings-empty'; empty.textContent = 'No settings match your search.'; empty.hidden = true;
for (const cluster of [...clusters, ...new Set(sections.map(s => s.cluster).filter(c => !clusters.includes(c)))]) {
const group = sections.filter(s => s.cluster === cluster); if (!group.length) continue;
const heading = document.createElement('h3'); heading.textContent = cluster; navigation.append(heading);
for (const section of group) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row'; button.dataset.section = section.id;
button.innerHTML = icon(section.icon);
const text = document.createElement('span'), title = document.createElement('b'), summary = document.createElement('small'); title.textContent = section.title; text.append(title, summary); button.append(text);
button.setAttribute('aria-controls', 'settings-section-' + section.id); navigation.append(button); rows.set(section.id, { button, summary });
const panel = document.createElement('section'); panel.id = 'settings-section-' + section.id; panel.className = 'settings-section'; panel.setAttribute('aria-label', section.title); panels.set(section.id, panel); detail.append(panel);
const cleanup = section.render(panel, context); if (typeof cleanup === 'function') cleanups.push(cleanup);
button.addEventListener('click', () => open(section.id));
}
}
grid.append(navigation, detail); host.replaceChildren(toolbar, results, empty, grid);
function summaries() { for (const section of sections) { try { rows.get(section.id).summary.textContent = section.summary(context) || ''; } catch {} } }
function show(id, inDetail, focusId) {
if (registry.get(id)) active = id;
host.dataset.detail = inDetail ? '1' : '0';
for (const [key, panel] of panels) panel.hidden = key !== active;
for (const [key, row] of rows) row.button.setAttribute('aria-current', key === active ? 'true' : 'false');
if (inDetail) {
try { win.sessionStorage.setItem('ytpSettingsSection', active); } catch {}
const target = focusId ? document.getElementById(focusId) : panels.get(active)?.querySelector('input,select,button,textarea,summary');
if (target) { const fold = target.closest('details'); if (fold) fold.open = true; target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'center', behavior: 'instant' }); }
}
}
function open(id, focusId) {
if (!panels.has(id)) return;
input.value = ''; filter();
win.history.pushState({ ...win.history.state, ytpSettingsSection: id }, '', win.location.href);
show(id, true, focusId);
}
function filter() {
const query = input.value.trim(); results.replaceChildren(); results.hidden = !query; grid.hidden = !!query;
if (!query) { empty.hidden = true; return; }
const entries = [];
for (const section of sections) {
entries.push({ title: section.title, description: section.description || '', section: section.id });
for (const el of panels.get(section.id).querySelectorAll('.set-row, .set-actions button, [data-settings-search]')) {
const control = el.matches('button,input,select') ? el : el.querySelector('input,select,button,textarea,summary');
if (control && !control.id) control.id = `settings-control-${section.id}-${entries.length}`;
const title = (el.querySelector('span')?.childNodes[0]?.textContent || el.textContent).trim();
entries.push({ title, description: `${section.title} ${el.textContent}`, section: section.id, control: control?.id });
}
}
const matches = search(entries, query); empty.hidden = matches.length > 0;
for (const item of matches.slice(0, 40)) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'settings-section-row';
const text = document.createElement('span'); text.textContent = item.title; const subtitle = document.createElement('small'); subtitle.textContent = registry.get(item.section).title; text.append(subtitle); button.append(text);
button.addEventListener('click', () => open(item.section, item.control)); results.append(button);
}
}
back.addEventListener('click', () => { if (win.history.state?.ytpSettingsSection) win.history.back(); else show(active, false); });
const pop = event => { if (!host.isConnected) return; show(event.state?.ytpSettingsSection || active, !!event.state?.ytpSettingsSection); if (!event.state?.ytpSettingsSection) rows.get(active)?.button.focus({ preventScroll: true }); };
win.addEventListener('popstate', pop); input.addEventListener('input', filter);
host.addEventListener('change', summaries); host.addEventListener('input', summaries);
const observer = new win.MutationObserver(summaries); observer.observe(detail, { childList: true, subtree: true, characterData: true });
const state = win.history.state?.ytpSettingsSection;
if (!state) win.history.replaceState({ ...win.history.state, ytpSettingsSection: null }, '', win.location.href);
show(state || active, !!state); summaries();
dispose = () => { observer.disconnect(); win.removeEventListener('popstate', pop); cleanups.forEach(fn => fn()); };
return { open, refresh: summaries, dispose };
}
const api = { ...registry, mount, search, normalize, createRegistry, clusters };
if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.SettingsSections = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Settings = require('./settings-sections');
test('registry supports extension sections, replacement and safe removal', () => {
const registry = Settings.createRegistry(), render = () => {};
const remove = registry.register({ id: 'pedals', title: 'MIDI pedals', render });
assert.equal(registry.get('pedals').cluster, 'Advanced & about');
registry.register({ id: 'pedals', title: 'Pedalboard', render: () => {} }); remove();
assert.equal(registry.get('pedals').title, 'Pedalboard');
assert.throws(() => registry.register({ id: '../bad', title: 'Invalid', render }));
});
test('settings search matches labels and help text across sections without case sensitivity', () => {
const rows = [{ title: 'Playback', description: 'Level volume between songs' }, { title: 'Cache size limit', description: 'Auto-evict oldest files' }];
assert.equal(Settings.search(rows, 'VOLUME songs')[0].title, 'Playback');
assert.equal(Settings.search(rows, 'oldest limit')[0].title, 'Cache size limit');
assert.equal(Settings.search(rows, 'unavailable').length, 0);
assert.equal(Settings.search(rows, '').length, 2);
});

View File

@@ -3973,9 +3973,9 @@ body.presenter-open { overflow: hidden; }
} }
.sleep-curtain span { color: #666; font-size: 14px; transition: opacity 2s; } .sleep-curtain span { color: #666; font-size: 14px; transition: opacity 2s; }
.sleep-curtain.dim span { opacity: 0; } .sleep-curtain.dim span { opacity: 0; }
.eq-bands { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-top: 8px; } .eq-bands { display: grid; grid-template-columns: repeat(5, minmax(44px, 1fr)); gap: 8px; margin-top: 8px; }
.eq-band { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); } .eq-band { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
.eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 28px; height: 150px; accent-color: var(--accent); } .eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 44px; height: 130px; accent-color: var(--accent); }
.eq-val { font-family: var(--mono); font-size: 11px; color: var(--text); min-height: 14px; } .eq-val { font-family: var(--mono); font-size: 11px; color: var(--text); min-height: 14px; }
.eq-f { font-family: var(--mono); } .eq-f { font-family: var(--mono); }
@@ -4351,3 +4351,8 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
.cache-fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .cache-fold > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.cache-fold-count { margin-left: auto; font-weight: 500; font-size: 13px; color: var(--text-dim); font-variant-numeric: tabular-nums; } .cache-fold-count { margin-left: auto; font-weight: 500; font-size: 13px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; } .cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; }
@media (min-width: 900px) { .modal:has(.eq-panel) { width: min(92vw, 780px); max-width: 780px; } .eq-bands { grid-template-columns: repeat(10, minmax(44px, 1fr)); } }
.lyric-primary { white-space: pre-line; }
.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }

View File

@@ -70,6 +70,7 @@ const SHELL = [
'/opfs-worker.js', '/opfs-worker.js',
'/video-edit.js', '/video-edit.js',
'/lyrics-core.js', '/lyrics-core.js',
'/lyrics-display.js',
'/stats-core.js', '/stats-core.js',
'/sha256.js', '/sha256.js',
'/resume-core.js', '/resume-core.js',
@@ -82,6 +83,28 @@ const SHELL = [
'/p2p-core.js', '/p2p-core.js',
'/p2p-transfer.js', '/p2p-transfer.js',
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js',
'/downloads.js',
'/downloads-page.js',
'/offline-pages.css',
'/saved-page.js',
'/server-backfill.js',
'/glass-controls.js',
'/theme-glass-controls.css',
'/saved-page.css',
'/party-dj.js',
'/lyrics-window.js',
'/midi.js',
'/lower-third.js',
'/vocal-reducer.js',
'/eq-core.js',
'/piano-core.js',
'/piano.js',
'/setlist-import.js',
'/car-mode.js',
'/car-mode.css',
'/lower-third.css',
'/settings-sections.css',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',
'/icons/icon-192.png', '/icons/icon-192.png',

View File

@@ -226,3 +226,12 @@ html[data-layout="classic"][data-density="compact"] .thumb { width: 88px; }
@media (max-width: 380px) { @media (max-width: 380px) {
html[data-layout="classic"] .thumb { width: 88px; } html[data-layout="classic"] .thumb { width: 88px; }
} }
/* Playlist cards carry a third action (✎ note). Without its own cell it
wrapped to a new row at the bottom-left; give the actions a 2×2 block:
✎ + on top, × under +. */
html[data-layout="classic"] .card:has(.card-note-btn) { grid-template-columns: 88px minmax(0, 1fr) 44px 44px; gap: 4px 12px; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-note-btn { grid-column: 3; grid-row: 1; align-self: start; justify-self: center; width: 44px; height: 44px; border-radius: 8px; opacity: 1; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-menu { grid-column: 4; grid-row: 1; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-del { grid-column: 4; grid-row: 2; }
html[data-layout="classic"] .card:has(.card-note-btn) { column-gap: 8px; }

View File

@@ -0,0 +1,53 @@
/* Now Playing's stroke and frosted-tile vocabulary, scoped to Glass Stage. */
html[data-layout="glass-stage"] .legacy-nav-icon,
html[data-layout="glass-stage"] .glass-original-glyph { display:none; }
html[data-layout="glass-stage"] :is(.classic-nav-icon,.glass-control-icon) { display:inline-block; flex:none; width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; vertical-align:middle; pointer-events:none; }
html[data-layout="glass-stage"] :is(#listPane button,.settings-section-row,.notes-tab,.mini-btn,.list-icon-btn) svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:1.9; stroke-linecap:round; stroke-linejoin:round; flex:none; }
html[data-layout="glass-stage"] :is(.bottom-nav-btn,.nav-item,.playlist-item) { min-height:44px; color:var(--text-2); }
html[data-layout="glass-stage"] .bottom-nav { gap:2px; padding-inline:4px; }
html[data-layout="glass-stage"] .bottom-nav-btn { border:1px solid transparent; border-radius:18px; gap:6px; min-width:44px; background:transparent; }
html[data-layout="glass-stage"] .bottom-nav-icon { display:grid; place-items:center; width:24px; height:24px; background:none; box-shadow:none; transform:none; }
html[data-layout="glass-stage"] .bottom-nav-label { font-size:11px; }
html[data-layout="glass-stage"] .bottom-nav-btn.active { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .bottom-nav-btn.active .bottom-nav-icon { background:none; box-shadow:none; transform:none; }
html[data-layout="glass-stage"] :is(.bottom-nav-badge,.nav-badge,.pl-count) { background:var(--tile-hi); border:1px solid var(--glass-line); color:var(--text-2); border-radius:99px; font-size:11px; min-width:18px; font-variant-numeric:tabular-nums; }
html[data-layout="glass-stage"] :is(.bottom-nav-btn.active .bottom-nav-badge,.nav-item.active .nav-badge) { color:var(--accent-bright); }
html[data-layout="glass-stage"] :is(.nav-item,.playlist-item) { border:1px solid transparent; border-radius:18px; gap:10px; padding:10px 12px; }
html[data-layout="glass-stage"] :is(.nav-item.active,.playlist-item.active) { color:var(--accent-bright); border-color:var(--glass-line); background:var(--tile-hi); }
html[data-layout="glass-stage"] :is(.nav-item,.playlist-item):hover { background:var(--tile); }
html[data-layout="glass-stage"] .smart-item .pl-name { display:flex; align-items:center; gap:10px; }
html[data-layout="glass-stage"] :is(#listPane button,.sidebar .icon-btn,.sb-profile-btn,.notes-tab,.mini-btn) { min-height:44px; min-width:44px; border-radius:18px; border:1px solid var(--glass-line); background:var(--tile); color:var(--text-2); box-shadow:none; }
html[data-layout="glass-stage"] :is(#listPane button,.sidebar .icon-btn,.sb-profile-btn,.notes-tab,.mini-btn):hover { background:var(--tile-hi); color:var(--text); border-color:var(--glass-line); }
html[data-layout="glass-stage"] :is(#listPane button,.sidebar button,.notes-tab,.mini-btn,.playlist-item):focus-visible { outline:2px solid var(--accent-bright); outline-offset:3px; }
html[data-layout="glass-stage"] :is(#listPane button,.notes-tab,.mini-btn):disabled { opacity:.5; cursor:not-allowed; }
html[data-layout="glass-stage"] :is(.card-menu,.card-del,.card-act,.saved-file-action,.mini-btn) { width:44px; height:44px; padding:11px; opacity:1; display:inline-flex; align-items:center; justify-content:center; flex:none; }
html[data-layout="glass-stage"] :is(.card-del,.saved-file-action.danger,.saved-clear,.dl-act.danger) { color:var(--accent-bright); }
html[data-layout="glass-stage"] :is(.settings-section-row[aria-current="true"],.notes-tab.active) { color:var(--accent-bright); background:var(--tile-hi); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .settings-section-row { margin-bottom:6px; }
html[data-layout="glass-stage"] .settings-section-row svg { color:inherit; }
html[data-layout="glass-stage"] #listActions button { height:auto; min-height:44px; font-size:12px; }
html[data-layout="glass-stage"] #listActions button::after { font-size:11px; }
html[data-layout="glass-stage"] .notes-tabs { display:flex; flex-wrap:wrap; gap:6px; padding:6px; border-radius:22px; background:var(--tile); border:1px solid var(--glass-line); }
html[data-layout="glass-stage"] .notes-tab { display:inline-flex; align-items:center; justify-content:center; gap:6px; padding:8px 10px; font-size:12px; }
html[data-layout="glass-stage"] .mini-bar-inner { gap:8px; }
html[data-layout="glass-stage"] .mini-close { color:var(--text-2); }
html[data-layout="glass-stage"] .mini-service { color:var(--accent-bright); }
html[data-layout="glass-stage"] .list-filter-bar input { min-height:44px; }
/* Reserve room for both row actions rather than squeezing the media title. */
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) { display:grid; grid-template-columns:72px minmax(0,1fr) 44px; align-items:center; gap:8px; }
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row):has(.card-del) { grid-template-columns:72px minmax(0,1fr) 44px 44px; }
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .thumb { grid-column:1; grid-row:1; width:72px; aspect-ratio:16/9; }
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-info { grid-column:2; grid-row:1; min-width:0; }
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-menu { grid-column:3; grid-row:1; }
html[data-layout="glass-stage"] .card:not(.saved-card):not(.dl-row) .card-del { grid-column:4; grid-row:1; }
html[data-layout="glass-stage"] .card .card-title { overflow:hidden; overflow-wrap:anywhere; }
html[data-layout="glass-stage"] .notes-fold { min-width:44px; min-height:44px; border-radius:18px; background:var(--tile); border:1px solid var(--glass-line); color:var(--text-2); }
html[data-layout="glass-stage"] .notes-count { font-size:11px; }
html[data-layout="glass-stage"] .bottom-nav-btn { padding:8px 2px; }
html[data-layout="glass-stage"] .bottom-nav-label { overflow:visible; text-overflow:clip; max-width:none; letter-spacing:0; }
/* Keep the section shortcuts away from right-hand file actions. */
html[data-layout="glass-stage"] .sec-rail { top:auto; left:12px; right:auto; bottom:var(--rail-bottom,24px); transform:none; width:max-content; max-width:calc(100% - 100px); flex-direction:row; gap:4px; padding:6px; border-radius:22px; background:var(--glass); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .sec-rail-btn { width:44px; height:44px; border-radius:18px; background:var(--tile); border:1px solid var(--glass-line); color:var(--text-2); }
html[data-layout="glass-stage"] .sec-rail-btn.active { background:var(--tile-hi); color:var(--accent-bright); border-color:var(--glass-line); }
html[data-layout="glass-stage"] .sec-rail-label { right:auto; left:0; bottom:50px; }
html[data-layout="glass-stage"] #listPane { padding-bottom:calc(var(--bottom-chrome,0px) + 88px) !important; }

21
frontend/vocal-reducer.js Normal file
View File

@@ -0,0 +1,21 @@
/* Mid/side centre reduction shared by live EQ and offline iPhone renders. */
(function(root) {
'use strict';
const amount = value => Math.max(0, Math.min(100, Number.isFinite(Number(value)) ? Number(value) : 100));
function coefficients(value) { const v = amount(value) / 100; return { same: (1 + v) / 2, cross: (v - 1) / 2 }; }
function process(left, right, value) { const c = coefficients(value); return [left * c.same + right * c.cross, right * c.same + left * c.cross]; }
function create(context, source, value = 100) {
const input = context.createGain(); input.channelCount = 2; input.channelCountMode = 'explicit'; input.channelInterpretation = 'speakers'; source.connect(input);
const splitter = context.createChannelSplitter(2), output = context.createChannelMerger(2); input.connect(splitter);
const matrix = [];
for (let from = 0; from < 2; from++) for (let to = 0; to < 2; to++) { const gain = context.createGain(); splitter.connect(gain, from); gain.connect(output, 0, to); matrix.push({ gain, same: from === to }); }
function set(next) { const c = coefficients(next); for (const entry of matrix) entry.gain.gain.setTargetAtTime(entry.same ? c.same : c.cross, context.currentTime, .03); }
// Initial values must be exact for OfflineAudioContext, without a ramp.
const c = coefficients(value); for (const entry of matrix) entry.gain.gain.value = entry.same ? c.same : c.cross;
return { output, set };
}
function register(adapter) { root.SettingsSections.register({ id: 'vocal-reducer', title: 'Vocal reducer', cluster: 'Listening', summary: () => `${amount(adapter.settings().vocalLevel ?? 100)}% · centre-panned vocals`, render(container) { const doc = container.ownerDocument, label = doc.createElement('label'); label.className = 'set-row'; const text = doc.createElement('span'); text.textContent = 'Vocal — reduces centre-panned vocals'; const input = doc.createElement('input'); input.type = 'range'; input.min = '0'; input.max = '100'; input.step = '1'; input.value = String(amount(adapter.settings().vocalLevel ?? 100)); input.setAttribute('aria-label', 'Vocal level: reduces centre-panned vocals'); const output = doc.createElement('output'); output.textContent = `${input.value}%`; input.oninput = () => { adapter.settings().vocalLevel = Number(input.value); output.textContent = `${input.value}%`; adapter.persist(); adapter.apply(); }; label.append(text, input, output); const note = doc.createElement('p'); note.textContent = '100% keeps the original mix; 0% removes its stereo centre. This can also reduce bass, drums and other centred instruments. Mono recordings may become silent. On iPhone, saved songs use processed copies; streamed songs require Live sound processing.'; container.append(label, note); } }); }
// TODO: model-based Demucs separation needs an optional server worker. See docs/vocal-stems.md.
function stems() { return { available: false, reason: 'Model-based stem separation is not implemented.' }; }
const api = { amount, coefficients, process, create, register, stems }; if (typeof module !== 'undefined') module.exports = api; else root.VocalReducer = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,3 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { process, coefficients } = require('./vocal-reducer');
test('100% preserves channels; zero cancels centre and retains side', () => { assert.deepEqual(process(.7, .2, 100), [.7, .2]); assert.deepEqual(process(.7, .7, 0), [0,0]); assert.deepEqual(process(1,-1,0), [1,-1]); assert.deepEqual(process(1,1,50), [.5,.5]); });
test('vocal settings clamp invalid values to safe bounds', () => { assert.deepEqual(coefficients(-99), coefficients(0)); assert.deepEqual(coefficients(999), coefficients(100)); assert.deepEqual(coefficients('invalid'), coefficients(100)); });

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch:/glass-controls\.spec\.js/ });

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /now-playing-return\.spec\.js/ });

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /offline-pages\.spec\.js/ });

View File

@@ -0,0 +1,6 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
...require('./playwright.classic.config'),
use: { ...require('./playwright.classic.config').use, ignoreHTTPSErrors: true },
testMatch: /piano-engine\.spec\.js/,
});

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /settings-sections\.spec\.js/ });

9
scripts/piano/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.10-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 OMP_NUM_THREADS=2
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libsndfile1 wget && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY worker.py .
VOLUME ["/models"]
CMD ["python", "worker.py"]

View File

@@ -0,0 +1,9 @@
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.5.1+cpu
piano-transcription-inference==0.0.6
numpy==1.23.5
librosa==0.9.2
numba==0.57.1
scipy==1.10.1
soundfile==0.12.1
mido==1.3.3

View File

@@ -0,0 +1,18 @@
import unittest
from worker import compact
class CompactNotesTest(unittest.TestCase):
def test_hand_split_and_velocity(self):
notes = compact([{'midi_note': 48, 'onset_time': 1.23456, 'offset_time': 1.78, 'velocity': 85}, {'midi_note': 60, 'onset_time': 2, 'offset_time': 3, 'velocity': 200}])
self.assertEqual(notes[0], {'pitch': 48, 'start': 1.235, 'end': 1.78, 'velocity': 85, 'hand': 'left'})
self.assertEqual(notes[1]['hand'], 'right')
self.assertEqual(notes[1]['velocity'], 127)
def test_empty_or_zero_duration(self):
self.assertEqual(compact([]), [])
self.assertEqual(compact([{'midi_note': 60, 'onset_time': 2, 'offset_time': 2, 'velocity': 85}]), [])
if __name__ == '__main__':
unittest.main()

123
scripts/piano/worker.py Normal file
View File

@@ -0,0 +1,123 @@
"""Optional CPU piano worker. Polls authenticated jobs; loads model on first job."""
import json
import os
import re
import tempfile
import threading
import time
import urllib.request
import urllib.error
from pathlib import Path
BASE = os.environ.get('YTP_BASE', 'http://ytplayer:3000').rstrip('/')
TOKEN = os.environ.get('YTP_TOKEN', '')
CHECKPOINT = Path(os.environ.get('PIANO_CHECKPOINT', '/models/piano.pth'))
MODEL_URL = 'https://zenodo.org/record/4034264/files/CRNN_note_F1%3D0.9677_pedal_F1%3D0.9186.pth?download=1'
ID = re.compile(r'^(?:[\w-]{11}|upl_[a-f0-9]{12})$')
def api(path, body):
request = urllib.request.Request(BASE + path, data=json.dumps(body).encode(), headers={'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json'})
with urllib.request.urlopen(request, timeout=45) as response:
return json.load(response)
def compact(events):
return [{'pitch': int(event['midi_note']), 'start': round(float(event['onset_time']), 3),
'end': round(float(event['offset_time']), 3), 'velocity': max(1, min(127, int(event['velocity']))),
'hand': 'left' if int(event['midi_note']) < 60 else 'right'}
for event in events if event['offset_time'] > event['onset_time']]
def download(url, destination, limit):
with urllib.request.urlopen(url, timeout=60) as response, open(destination, 'wb') as target:
size = 0
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > limit:
raise ValueError('Download exceeds worker limit')
target.write(chunk)
def work(job, model):
video = job['videoId']
if not ID.fullmatch(video):
raise ValueError('Invalid job id')
path = '/api/uploads/' + video if video.startswith('upl_') else '/api/media/' + video + '?a=1'
if job['audioPath'] != path:
raise ValueError('Invalid audio path')
endpoint = '/api/piano-worker/' + video
stop = threading.Event()
def heartbeat():
while not stop.wait(30):
try:
api(endpoint, {'action': 'heartbeat', 'lease': job['lease']})
except Exception:
# Completion still checks the lease; an expired worker cannot publish.
pass
thread = threading.Thread(target=heartbeat, daemon=True)
thread.start()
try:
if model is None:
if not CHECKPOINT.exists() or CHECKPOINT.stat().st_size < 160000000:
CHECKPOINT.parent.mkdir(parents=True, exist_ok=True)
temporary = CHECKPOINT.with_suffix('.part')
download(MODEL_URL, temporary, 250000000)
if temporary.stat().st_size < 160000000:
raise ValueError('Incomplete model checkpoint')
temporary.replace(CHECKPOINT)
import torch
from piano_transcription_inference import PianoTranscription
torch.set_num_threads(int(os.environ.get('PIANO_THREADS', '2')))
model = PianoTranscription(device='cpu', checkpoint_path=str(CHECKPOINT))
from piano_transcription_inference import load_audio, sample_rate
with tempfile.TemporaryDirectory() as folder:
media = Path(folder) / 'audio.bin'
try:
download(BASE + path, media, 100 * 1048576)
except urllib.error.HTTPError as error:
if error.code != 404 or video.startswith('upl_'):
raise
download(BASE + path.split('?')[0], media, 100 * 1048576)
audio, _ = load_audio(str(media), sr=sample_rate, mono=True)
if len(audio) / sample_rate > 900:
raise ValueError('Worker audio is limited to 15 minutes')
result = model.transcribe(audio, None)
notes = compact(result['est_note_events'])
if len(notes) > 30000:
raise ValueError('Too many notes')
api(endpoint, {'action': 'complete', 'lease': job['lease'], 'notes': notes})
return model
except Exception as error:
api(endpoint, {'action': 'fail', 'lease': job['lease'], 'error': str(error)[:500]})
return model
finally:
stop.set()
thread.join(timeout=1)
def main():
if len(TOKEN) < 24:
print('Piano worker disabled: configure a dedicated worker token.', flush=True)
while True:
time.sleep(300)
model = None
while True:
try:
job = api('/api/piano-worker/claim', {}).get('job')
if job:
model = work(job, model)
else:
time.sleep(10)
except Exception as error:
print('Piano worker: ' + str(error), flush=True)
time.sleep(15)
if __name__ == '__main__':
main()

31
server/media-meta-core.js Normal file
View File

@@ -0,0 +1,31 @@
// Device hints only fill gaps; extractor metadata stays authoritative.
export function mergeMissing(existing = {}, incoming = {}) {
const out = { ...existing };
for (const [key, value] of Object.entries(incoming)) {
const old = out[key];
if (old == null || old === '' || old === 0 || (key === 'title' && (old === '(untitled)' || old === out.id))) out[key] = value;
}
if (!existing.channel && (existing.uploader || existing.artist)) out.channel = existing.uploader || existing.artist;
return out;
}
export function validateMeta(id, body) {
if (!/^[A-Za-z0-9_-]{11}$/.test(id) || !body || typeof body !== 'object' || Array.isArray(body) || (body.id != null && body.id !== id) || body.custom || body.upload) throw new Error('Invalid YouTube video');
const clean = (value, required = false) => {
if (value == null && !required) return '';
if (typeof value !== 'string' || value.length > 300 || /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(value)) throw new Error('Invalid title or artist');
const text = value.trim(); if (required && (!text || text === '(untitled)' || text === id)) throw new Error('A video title is required'); return text;
};
const title = clean(body.title, true), channel = clean(body.channel || body.artist);
const duration = body.duration ?? 0;
if (typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0 || duration > 86400) throw new Error('Invalid duration');
let thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
if (body.thumbnail) {
if (typeof body.thumbnail !== 'string' || body.thumbnail.length > 2048) throw new Error('Invalid thumbnail');
if (body.thumbnail !== `/api/catalog/${id}/thumbnail`) {
let url; try { url = new URL(body.thumbnail); } catch { throw new Error('Invalid thumbnail'); }
if (url.protocol !== 'https:' || !/^(?:i|i\d)\.ytimg\.com$/.test(url.hostname) || url.port || url.username || url.password) throw new Error('Invalid thumbnail');
thumbnail = url.href;
}
}
return { id, title, channel, duration, thumbnail };
}

66
server/media-meta.js Normal file
View File

@@ -0,0 +1,66 @@
import { validateMeta, mergeMissing } from './media-meta-core.js';
async function readBody(request) {
if (Number(request.headers.get('content-length')) > 8192) throw new Error('Metadata is too large');
const reader = request.body?.getReader(); if (!reader) throw new Error('Metadata is required');
const chunks = []; let size = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
size += value.byteLength; if (size > 8192) throw new Error('Metadata is too large'); chunks.push(value);
}
} finally { await reader.cancel().catch(() => {}); }
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return JSON.parse(new TextDecoder().decode(bytes));
}
export function createLimiter({ now = Date.now, perClient = 20, global = 100 } = {}) {
const clients = new Map(); let windowStart = now(), count = 0;
return key => {
const time = now();
if (time - windowStart >= 60000) { clients.clear(); count = 0; windowStart = time; }
if (count >= global || (clients.get(key) || 0) >= perClient || (!clients.has(key) && clients.size >= 2000)) return false;
clients.set(key, (clients.get(key) || 0) + 1); count++; return true;
};
}
export function registerMediaMetaRoutes(app, { db, catalog, media, now = Date.now, limiter = createLimiter({ now }), maxPending = 32, log = console }) {
const pending = new Map();
app.post('/api/media/:id/meta', async c => {
const who = (c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'local').split(',')[0].trim();
if (!limiter(who)) return c.json({ ok: false, error: 'Too many metadata reports' }, 429, { 'Retry-After': '60' });
let card;
try { card = validateMeta(c.req.param('id'), await readBody(c.req.raw)); }
catch (error) { return c.json({ ok: false, error: error.message }, 400); }
try {
const old = await db.getMedia(card.id);
const catalogRow = (await db.db.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [card.id] })).rows[0];
let richer = {}; try { richer = JSON.parse(catalogRow?.card || '{}'); } catch { /* absent */ }
const hint = mergeMissing(richer, card);
// SQL merges against the row at write time, so a concurrent extractor
// update cannot be overwritten by a stale device read.
await db.db.execute({ sql: `INSERT INTO media_cache (video_id,meta,duration,status,auto,created_at,updated_at,last_access)
VALUES (?,?,?,'failed',1,?,?,?) ON CONFLICT(video_id) DO UPDATE SET
meta=json_patch(media_cache.meta, (SELECT COALESCE(json_group_object(key,value),'{}') FROM json_each(excluded.meta)
WHERE (json_extract(media_cache.meta,'$.'||key) IS NULL OR json_extract(media_cache.meta,'$.'||key) IN ('',0)
OR (key='title' AND json_extract(media_cache.meta,'$.title') IN ('(untitled)',media_cache.video_id)))
AND NOT (key='channel' AND COALESCE(NULLIF(json_extract(media_cache.meta,'$.uploader'),''),NULLIF(json_extract(media_cache.meta,'$.artist'),''),'')!=''))),
duration=CASE WHEN media_cache.duration<=0 THEN excluded.duration ELSE media_cache.duration END`,
args: [card.id, JSON.stringify(Object.fromEntries(Object.keys(card).map(key => [key, hint[key]]))), hint.duration || 0, now(), now(), now()] });
// Keep the recommendation/thumbnail catalog in step with the media row.
const row = await db.getMedia(card.id), saved = JSON.parse(row.meta);
await catalog.ingest([{ ...saved, channel: saved.channel || saved.uploader || saved.artist || '', id: card.id }], 'device-play', { fillMissing: true });
let cache = 'queued';
if (await media.getReady(card.id)) cache = 'ready';
else if (!media.available()) cache = 'unavailable';
else if (!pending.has(card.id)) {
if (pending.size >= maxPending) cache = 'busy';
else {
const work = media.ensureCached(card.id, { priority: 1, auto: true });
pending.set(card.id, work);
work.catch(error => log.warn?.(`[media-meta] ${card.id}: ${error.message}`)).finally(() => pending.delete(card.id));
}
}
return c.json({ ok: true, known: !!old, cache }, cache === 'ready' ? 200 : 202, { 'Cache-Control': 'no-store' });
} catch (error) { log.warn?.('[media-meta]', error.message); return c.json({ ok: false, error: 'Could not register video metadata' }, 500); }
});
}

58
server/media-meta.test.js Normal file
View File

@@ -0,0 +1,58 @@
import { test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
import { validateMeta, mergeMissing } from './media-meta-core.js';
import { registerMediaMetaRoutes, createLimiter } from './media-meta.js';
const root = mkdtempSync(join(tmpdir(),'ytp-device-meta-'));
process.env.DB_PATH = join(root,'test.db');
const db = await import('./db.js'), catalog = await import('./video-catalog.js');
const id='0gfX0dFLaBc', card={id,title:'Because You are God',artist:'Cathedral of Praise Worship',duration:240};
let calls=0, online=true, ready=false;
const app=new Hono();
registerMediaMetaRoutes(app,{db,catalog,media:{getReady:async()=>ready,available:()=>online,status:async()=>({status:'failed'}),ensureCached:()=>{calls++;return new Promise(()=>{});}},log:{warn(){}}});
const post=(video=id,body=card)=>app.request(`/api/media/${video}/meta`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
beforeAll(db.initDb);afterAll(()=>{db.db.close();rmSync(root,{recursive:true,force:true});});
test('validation rejects non-YouTube ids, mismatches, malformed fields and unsafe thumbnail URLs',()=>{
for(const body of [{...card,id:'bbbbbbbbbbb'},{...card,title:{}},{...card,duration:Infinity},{...card,duration:-1},{...card,thumbnail:'http://127.0.0.1/art'},{...card,thumbnail:'https://i.ytimg.com:1234/art'}])expect(()=>validateMeta(id,body)).toThrow();
expect(()=>validateMeta('upl_123',card)).toThrow();
expect(validateMeta(id,card).channel).toBe(card.artist);
expect(validateMeta(id,{...card,thumbnail:`/api/catalog/${id}/thumbnail`}).thumbnail).toContain('https://i.ytimg.com/');
expect(()=>validateMeta(id,{...card,title:'a'.repeat(301)})).toThrow();
});
test('merge only fills gaps and retains richer titles, durations, art and artist aliases',()=>{
const rich={title:'Full server title',duration:250,uploader:'Server artist',description:'Rich description',thumbnail:'rich-art'};
expect(mergeMissing(rich,validateMeta(id,card))).toMatchObject({...rich,channel:'Server artist'});
expect(mergeMissing({id,title:id,duration:0},validateMeta(id,card))).toMatchObject({title:card.title,duration:240});
});
test('unknown phone song gets a media row, catalog entry and one background cache job',async()=>{
const response=await post();expect(response.status).toBe(202);expect((await response.json()).known).toBe(false);
const row=await db.getMedia(id);expect(JSON.parse(row.meta).title).toBe(card.title);expect(JSON.parse(row.meta).channel).toBe(card.artist);expect(row.duration).toBe(240);
expect((await db.db.execute({sql:'SELECT card FROM video_meta WHERE id=?',args:[id]})).rows).toHaveLength(1);
await post();expect(calls).toBe(1);
});
test('atomic missing-field SQL and catalog merge retain existing server metadata',async()=>{
const video='bbbbbbbbbbb';
await db.upsertMedia(video,{meta:JSON.stringify({title:'Server title',uploader:'Server artist',duration:300,thumbnail:'https://i.ytimg.com/vi/bbbbbbbbbbb/maxresdefault.jpg',tags:['rich']}),duration:300,status:'ready'});
await catalog.ingest([{id:video,title:'Catalog title',channel:'Catalog artist',duration:310,tags:['catalog']}]);
ready=true;const before=calls;const response=await post(video,{...card,id:video});expect(response.status).toBe(200);expect(calls).toBe(before);ready=false;
const meta=JSON.parse((await db.getMedia(video)).meta);expect(meta.title).toBe('Server title');expect(meta.uploader).toBe('Server artist');expect(meta.channel).toBeUndefined();expect(meta.tags).toEqual(['rich']);expect((await db.getMedia(video)).duration).toBe(300);
const c=JSON.parse((await db.db.execute({sql:'SELECT card FROM video_meta WHERE id=?',args:[video]})).rows[0].card);expect(c.title).toBe('Catalog title');expect(c.channel).toBe('Catalog artist');
});
test('offline cache volume retains labels without queuing a fetch',async()=>{
online=false;const before=calls;const response=await post('ccccccccccc',{...card,id:'ccccccccccc'});expect((await response.json()).cache).toBe('unavailable');expect(calls).toBe(before);expect(JSON.parse((await db.getMedia('ccccccccccc')).meta).title).toBe(card.title);online=true;
});
test('bounded body and rate limits reject expensive or repeated reports',async()=>{
expect((await post(id,{...card,title:'x'.repeat(10000)})).status).toBe(400);
let time=0;const limited=createLimiter({now:()=>time,perClient:2,global:3});expect(limited('a')).toBe(true);expect(limited('a')).toBe(true);expect(limited('a')).toBe(false);expect(limited('b')).toBe(true);expect(limited('c')).toBe(false);time=60000;expect(limited('a')).toBe(true);
const throttled=new Hono();registerMediaMetaRoutes(throttled,{db,catalog,media:{},limiter:()=>false});expect((await throttled.request(`/api/media/${id}/meta`,{method:'POST',body:'{}'})).status).toBe(429);
});
test('a full backfill queue keeps song metadata without starting another download',async()=>{
let fetched=0;const bounded=new Hono();
registerMediaMetaRoutes(bounded,{db,catalog,maxPending:1,media:{getReady:async()=>false,available:()=>true,ensureCached:()=>{fetched++;return new Promise(()=>{});}},log:{warn(){}}});
const report=video=>bounded.request(`/api/media/${video}/meta`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...card,id:video})});
expect((await (await report('ddddddddddd')).json()).cache).toBe('queued');
expect((await (await report('eeeeeeeeeee')).json()).cache).toBe('busy');expect(fetched).toBe(1);
expect(JSON.parse((await db.getMedia('eeeeeeeeeee')).meta).title).toBe(card.title);
});

View File

@@ -87,7 +87,9 @@ export function sanitizeLyrics(input) {
const text = cleanLyricText(l.text); const text = cleanLyricText(l.text);
if (!text) continue; if (!text) continue;
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line'; const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
lines.push({ t: cleanTime(l.t), text, kind }); const line = { t: cleanTime(l.t), text, kind };
if (kind === 'line') { const secondary = cleanLyricText(l.secondary), phonetic = cleanLyricText(l.phonetic); if (secondary) line.secondary = secondary; if (phonetic) line.phonetic = phonetic; }
lines.push(line);
} }
const tags = (Array.isArray(input.tags) ? input.tags : []) const tags = (Array.isArray(input.tags) ? input.tags : [])
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS); .map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);
@@ -685,14 +687,14 @@ export function registerNoteRoutes(app, deps) {
// Saved (server-cached) videos with whether each already has lyrics — the // Saved (server-cached) videos with whether each already has lyrics — the
// work list for batch lyric injection. // work list for batch lyric injection.
app.get('/api/admin/media', requireAdminOrToken, async (c) => { app.get('/api/admin/media', requireAdminOrToken, async (c) => {
const rows = (await db.listMedia()).filter((r) => r.status === 'ready'); const rows = await db.listMedia();
const out = []; const out = [];
for (const r of rows) { for (const r of rows) {
let meta = {}; let meta = {};
try { meta = JSON.parse(r.meta || '{}'); } catch { /* corrupt meta */ } try { meta = JSON.parse(r.meta || '{}'); } catch { /* corrupt meta */ }
const n = await db.getNotes(r.video_id); const n = await db.getNotes(r.video_id);
out.push({ out.push({
id: r.video_id, title: meta.title || '', channel: meta.channel || meta.uploader || '', id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0, duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0, lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
}); });

View File

@@ -267,3 +267,16 @@ test('saving a grouped cue preserves one timestamp in current and revision docum
const flags = await (await app.request(`/api/notes/${id}/flags`)).json(); const flags = await (await app.request(`/api/notes/${id}/flags`)).json();
expect(flags.flags[0].text).toBe(lines[0].text); expect(flags.flags[0].text).toBe(lines[0].text);
}); });
test('secondary and phonetic lyric fields survive validation without changing old documents', () => {
const result = N.sanitizeLyrics({ lines:[{t:1,text:'主',kind:'line',secondary:' Your grace\n is enough ',phonetic:' zhǔ\u0007 '},{t:2,text:'Legacy',kind:'line'}] });
expect(result.lines[0].secondary).toBe('Your grace\nis enough'); expect(result.lines[0].phonetic).toBe('zhǔ'); expect(result.lines[1]).toEqual({t:2,text:'Legacy',kind:'line'});
});
test('admin song list includes metadata-only phone songs for lyrics lookup', async () => {
const id = 'phoneSong01';
await dbmod.upsertMedia(id, { status: 'failed', duration: 240, meta: JSON.stringify({ title: 'Phone song', channel: 'Phone artist' }) });
const cookie = await adminCookie();
const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } });
const song = (await res.json()).media.find(x => x.id === id);
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed' });
});

26
server/party-dj.js Normal file
View File

@@ -0,0 +1,26 @@
// Server-authoritative queue. Votes belong to the participant id across reconnects.
export function createDJQueue() { return { enabled: false, entries: [], sequence: 0 }; }
export function queueSnapshot(queue) {
return { enabled: queue.enabled, entries: [...queue.entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.size - a.votes.size || a.order - b.order).map(({ votes, order, ...entry }) => ({ ...entry, votes: [...votes] })) };
}
export function changeDJQueue(queue, message, participant, host) {
if (message.action === 'enable') { if (!host) return false; queue.enabled = !!message.enabled; return true; }
if (!queue.enabled) return false;
const entry = queue.entries.find(e => e.video.id === message.id);
if (message.action === 'suggest') {
const v = message.video; if (!v || !/^[A-Za-z0-9_-]{11}$/.test(v.id) || queue.entries.length >= 100 || queue.entries.some(e => e.video.id === v.id)) return false;
const clean = value => String(value || '').replace(/[\u0000-\u001f<>]/g, '').slice(0, 200);
queue.entries.push({ video: { id: v.id, title: clean(v.title) || v.id, channel: clean(v.channel), thumbnail: `/api/thumb/${v.id}` }, from: participant, approved: host, votes: new Set(), order: queue.sequence++ }); return true;
}
if (!entry) return false;
if (message.action === 'vote') { if (entry.votes.has(participant)) return false; entry.votes.add(participant); return true; }
if (!host) return false;
if (message.action === 'approve') { entry.approved = true; return true; }
if (message.action === 'remove') { queue.entries = queue.entries.filter(e => e !== entry); return true; }
return false;
}
export function takeDJVideo(queue) {
if (!queue.enabled) return null;
const next = queueSnapshot(queue).entries.find(e => e.approved); if (!next) return null;
queue.entries = queue.entries.filter(e => e.video.id !== next.video.id); return next.video;
}

12
server/party-dj.test.js Normal file
View File

@@ -0,0 +1,12 @@
import { test, expect } from 'bun:test';
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
test('host controls mode and approval; participant votes survive reconnects', () => {
const q = createDJQueue(); const suggest = { action: 'suggest', video: { id: '0gfX0dFLaBc', title: '<Song>' } };
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(changeDJQueue(q, { action: 'enable', enabled: true }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'enable', enabled: true }, 'host', true); expect(changeDJQueue(q, suggest, 'guest', false)).toBe(true);
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(takeDJVideo(q)).toBeNull();
const vote = { action: 'vote', id: suggest.video.id }; expect(changeDJQueue(q, vote, 'guest', false)).toBe(true); expect(changeDJQueue(q, vote, 'guest', false)).toBe(false);
expect(changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'host', true); expect(queueSnapshot(q).entries[0].votes).toEqual(['guest']); expect(takeDJVideo(q).title).toBe('Song'); expect(q.entries).toHaveLength(0);
});
test('ranked approved queue consumes highest voted song, with bounded metadata', () => { const q = createDJQueue(); q.enabled = true; for (const id of ['abcdefghijk', 'ABCDEFGHIJK']) changeDJQueue(q, { action: 'suggest', video: { id, title: 'x'.repeat(1000), thumbnail: 'bad' } }, 'host', true); changeDJQueue(q, { action: 'vote', id: 'ABCDEFGHIJK' }, 'guest', false); expect(takeDJVideo(q).id).toBe('ABCDEFGHIJK'); expect(queueSnapshot(q).entries[0].video.title.length).toBe(200); expect(changeDJQueue(q, { action: 'suggest', video: { id: 'bad' } }, 'guest', false)).toBe(false); });

View File

@@ -18,6 +18,7 @@
* link); the host resumes it with the secret it was given. * link); the host resumes it with the secret it was given.
* ========================================================================== */ * ========================================================================== */
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
@@ -88,7 +89,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; } if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
if (!p) { if (!p) {
const code = newCode(); const code = newCode();
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], allowControl: false, hostOnline: true, hostLeftAt: 0 }; p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p); parties.set(code, p);
} }
p.hostPid = d.pid; p.hostPid = d.pid;
@@ -103,7 +104,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 }); p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
send(ws, { send(ws, {
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
now: now(), members: members(p), state: p.state, chat: p.chat, allowControl: p.allowControl, now: now(), members: members(p), state: p.state, chat: p.chat, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
}); });
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws); broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
} }
@@ -117,7 +118,10 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const me = p && p.members.get(d.pid); const me = p && p.members.get(d.pid);
if (!me || me.ws !== ws || !m || typeof m !== 'object') return; if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
const isHost = d.pid === p.hostPid; const isHost = d.pid === p.hostPid;
if (m.type === 'state' && isHost) { if (m.type === 'dj') {
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
} else if (m.type === 'state' && isHost) {
p.state = { ...(m.state || {}), ts: now() }; p.state = { ...(m.state || {}), ts: now() };
broadcast(p, { type: 'state', state: p.state }, ws); broadcast(p, { type: 'state', state: p.state }, ws);
} else if (m.type === 'settings' && isHost) { } else if (m.type === 'settings' && isHost) {

View File

@@ -115,3 +115,14 @@ describe('watch party', () => {
expect(await guest.closed).toBe(4010); expect(await guest.closed).toBe(4010);
}); });
}); });
test('DJ suggestions and votes are broadcast, host consumes approved songs', async () => {
const h = client({ role: 'host', pid: 'djhost00001', name: 'Host' }); const hello = await h.next('hello');
const g = client({ role: 'guest', pid: 'djguest0001', name: 'Guest', code: hello.code }); await g.next('hello');
h.send({ type: 'dj', action: 'enable', enabled: true }); expect((await g.next('dj')).enabled).toBe(true); await h.next('dj');
g.send({ type: 'dj', action: 'suggest', video: { id: '0gfX0dFLaBc', title: 'Song' } }); expect((await h.next('dj')).entries[0].approved).toBe(false); await g.next('dj');
g.send({ type: 'dj', action: 'vote', id: '0gfX0dFLaBc' }); expect((await h.next('dj')).entries[0].votes).toEqual(['djguest0001']); await g.next('dj');
h.send({ type: 'dj', action: 'approve', id: '0gfX0dFLaBc' }); expect((await g.next('dj')).entries[0].approved).toBe(true); await h.next('dj');
h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]);
h.ws.close(); g.ws.close();
});

185
server/piano.js Normal file
View File

@@ -0,0 +1,185 @@
// Optional leased piano jobs; the heavy model always lives in a separate worker.
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
import { db as sql } from './db.js';
const { normalizeNotes } = createRequire(import.meta.url)(
existsSync(new URL('../frontend/piano-core.js', import.meta.url))
? '../frontend/piano-core.js'
: './public/piano-core.js',
);
const ID = /^(?:[\w-]{11}|upl_[a-f0-9]{12})$/;
const LEASE = 120000;
const sourceKey = (row) =>
String(row?.sha256 || row?.gen || row?.path || row?.created_at || 'original');
export function registerPianoRoutes(
app,
{ db, adminAuth, enabled = false, workerToken = '', now = Date.now },
) {
enabled = enabled && workerToken.length >= 24;
const ready = sql.execute(`CREATE TABLE IF NOT EXISTS piano_jobs (
video_id TEXT PRIMARY KEY,status TEXT NOT NULL,source_key TEXT NOT NULL,notes TEXT,error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,lease_token TEXT,lease_until INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`);
const source = async (id) =>
id.startsWith('upl_')
? db.getUpload(id)
: db.getMedia(id).then((row) => (row?.status === 'ready' ? row : null));
const get = async (id) =>
(await sql.execute({ sql: 'SELECT * FROM piano_jobs WHERE video_id=?', args: [id] })).rows[0];
const publicJob = (row) =>
row
? {
videoId: row.video_id,
status: row.status,
error: row.error,
notes: row.notes ? JSON.parse(row.notes) : null,
updatedAt: Number(row.updated_at),
}
: null;
async function workerAuth(c, next) {
const token = (c.req.header('authorization') || '').replace(/^Bearer\s+/i, '');
const a = Buffer.from(token),
b = Buffer.from(workerToken);
if (!enabled || a.length !== b.length || !timingSafeEqual(a, b))
return c.json({ ok: false, error: 'Unauthorized worker' }, 401);
await ready;
return next();
}
app.get('/api/media/:id/piano', async (c) => {
await ready;
const id = c.req.param('id');
if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400);
const row = await get(id),
media = await source(id);
const stale = row && media && sourceKey(media) !== row.source_key;
return c.json(
{
ok: true,
enabled,
job: stale
? {
videoId: id,
status: 'stale',
notes: null,
error: 'The source changed. Transcribe it again.',
}
: publicJob(row),
},
200,
{ 'Cache-Control': 'no-store' },
);
});
app.post('/api/media/:id/piano', adminAuth, async (c) => {
await ready;
if (!enabled) return c.json({ ok: false, error: 'The optional piano worker is off.' }, 503);
const id = c.req.param('id');
if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400);
const media = await source(id);
if (!media) return c.json({ ok: false, error: 'Save this song on the server first.' }, 409);
if (Number(media.duration) > 900)
return c.json({ ok: false, error: 'Piano jobs are limited to 15 minutes.' }, 422);
const old = await get(id);
if (
old &&
['queued', 'running', 'ready'].includes(old.status) &&
old.source_key === sourceKey(media)
)
return c.json({ ok: true, job: publicJob(old) });
const active = Number(
(await sql.execute("SELECT COUNT(*) n FROM piano_jobs WHERE status IN ('queued','running')"))
.rows[0].n,
);
if (active >= 10) return c.json({ ok: false, error: 'Piano queue is full.' }, 429);
await sql.execute({
sql: `INSERT INTO piano_jobs(video_id,status,source_key,created_at,updated_at) SELECT ?,'queued',?,?,? WHERE (SELECT COUNT(*) FROM piano_jobs WHERE status IN ('queued','running'))<10
ON CONFLICT(video_id) DO UPDATE SET status='queued',source_key=excluded.source_key,notes=NULL,error=NULL,attempts=0,lease_token=NULL,lease_until=0,updated_at=excluded.updated_at`,
args: [id, sourceKey(media), now(), now()],
});
const queued = await get(id);
if (
!queued ||
!['queued', 'running'].includes(queued.status) ||
queued.source_key !== sourceKey(media)
)
return c.json({ ok: false, error: 'Piano queue is full.' }, 429);
return c.json({ ok: true, job: publicJob(queued) }, 202);
});
app.post('/api/piano-worker/claim', workerAuth, async (c) => {
const time = now();
await sql.execute({
sql: "UPDATE piano_jobs SET status='failed',error='Worker stopped repeatedly',updated_at=? WHERE status='running' AND lease_until<? AND attempts>=3",
args: [time, time],
});
const row = (
await sql.execute({
sql: `UPDATE piano_jobs SET status='running',attempts=attempts+1,lease_token=?,lease_until=?,updated_at=?
WHERE video_id=(SELECT video_id FROM piano_jobs WHERE (status='queued' OR (status='running' AND lease_until<?)) AND attempts<3 ORDER BY created_at LIMIT 1) RETURNING *`,
args: [randomUUID(), time + LEASE, time, time],
})
).rows[0];
return c.json({
ok: true,
job: row
? {
...publicJob(row),
lease: row.lease_token,
audioPath: row.video_id.startsWith('upl_')
? `/api/uploads/${row.video_id}`
: `/api/media/${row.video_id}?a=1`,
}
: null,
});
});
app.post('/api/piano-worker/:id', workerAuth, async (c) => {
const id = c.req.param('id');
if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400);
const text = await c.req.text();
if (text.length > 4 * 1048576) return c.json({ ok: false, error: 'Result too large' }, 413);
let body;
try {
body = JSON.parse(text);
} catch {
return c.json({ ok: false, error: 'Invalid JSON' }, 400);
}
const row = await get(id);
if (
!row ||
row.status !== 'running' ||
row.lease_token !== body.lease ||
Number(row.lease_until) < now()
)
return c.json({ ok: false, error: 'Job lease expired' }, 409);
if (body.action === 'heartbeat') {
await sql.execute({
sql: 'UPDATE piano_jobs SET lease_until=?,updated_at=? WHERE video_id=? AND lease_token=?',
args: [now() + LEASE, now(), id, body.lease],
});
return c.json({ ok: true });
}
let notes = null,
status = 'failed';
if (body.action === 'complete') {
try {
notes = normalizeNotes(body.notes);
} catch (error) {
return c.json({ ok: false, error: error.message }, 422);
}
status = 'ready';
} else if (body.action !== 'fail')
return c.json({ ok: false, error: 'Unknown worker action' }, 400);
await sql.execute({
sql: 'UPDATE piano_jobs SET status=?,notes=?,error=?,updated_at=?,lease_token=NULL,lease_until=0 WHERE video_id=? AND lease_token=?',
args: [
status,
notes ? JSON.stringify(notes) : null,
status === 'failed' ? String(body.error || 'Transcription failed').slice(0, 500) : null,
now(),
id,
body.lease,
],
});
return c.json({ ok: true });
});
return { ready };
}

103
server/piano.test.js Normal file
View File

@@ -0,0 +1,103 @@
import { test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-piano-'));
process.env.DB_PATH = join(root, 'test.db');
const db = await import('./db.js');
const { registerPianoRoutes } = await import('./piano.js');
const ID = '0gfX0dFLaBc',
TOKEN = 'piano-worker-test-'.repeat(3);
let app,
time = 1000;
const post = (path, body = {}, token = TOKEN) =>
app.request(path, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
beforeAll(async () => {
await db.initDb();
await db.upsertMedia(ID, { status: 'ready', duration: 60, gen: 1 });
app = new Hono();
await registerPianoRoutes(app, {
db,
enabled: true,
workerToken: TOKEN,
now: () => time,
adminAuth: async (c, next) =>
c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401),
}).ready;
});
afterAll(async () => {
db.db.close();
rmSync(root, { recursive: true, force: true });
});
test('server piano requires admin requests and dedicated worker authentication', async () => {
expect((await post(`/api/media/${ID}/piano`)).status).toBe(401);
expect((await post('/api/piano-worker/claim', {}, 'wrong')).status).toBe(401);
const result = await app.request(`/api/media/${ID}/piano`, {
method: 'POST',
headers: { 'x-admin': 'yes' },
});
expect(result.status).toBe(202);
expect((await result.json()).job.status).toBe('queued');
});
test('claim is exclusive; validated results persist as compact notes with inferred hands', async () => {
const first = await (await post('/api/piano-worker/claim')).json();
expect(first.job.audioPath).toBe(`/api/media/${ID}?a=1`);
expect((await (await post('/api/piano-worker/claim')).json()).job).toBeNull();
expect(
(await post(`/api/piano-worker/${ID}`, { action: 'complete', lease: 'wrong', notes: [] }))
.status,
).toBe(409);
expect(
(
await post(`/api/piano-worker/${ID}`, {
action: 'complete',
lease: first.job.lease,
notes: [{ pitch: 60, start: 2, end: 1, velocity: 80 }],
})
).status,
).toBe(422);
expect(
(
await post(`/api/piano-worker/${ID}`, {
action: 'complete',
lease: first.job.lease,
notes: [{ pitch: 48, start: 1.24, end: 1.78, velocity: 85 }],
})
).status,
).toBe(200);
const result = await (await app.request(`/api/media/${ID}/piano`)).json();
expect(result.job.notes[0]).toEqual({
pitch: 48,
start: 1.24,
end: 1.78,
velocity: 85,
hand: 'left',
});
});
test('changed source invalidates notes, lost leases are re-claimed and stale workers cannot publish', async () => {
await db.upsertMedia(ID, { gen: 2 });
expect((await (await app.request(`/api/media/${ID}/piano`)).json()).job.status).toBe('stale');
await app.request(`/api/media/${ID}/piano`, { method: 'POST', headers: { 'x-admin': 'yes' } });
const old = (await (await post('/api/piano-worker/claim')).json()).job;
time += 120001;
const next = (await (await post('/api/piano-worker/claim')).json()).job;
expect(next.lease).not.toBe(old.lease);
expect(
(await post(`/api/piano-worker/${ID}`, { action: 'complete', lease: old.lease, notes: [] }))
.status,
).toBe(409);
expect(
(await post(`/api/piano-worker/${ID}`, { action: 'heartbeat', lease: next.lease })).status,
).toBe(200);
});
test('optional server pipeline defaults off and rejects malformed ids', async () => {
const disabled = new Hono();
await registerPianoRoutes(disabled, { db, adminAuth: async (c, next) => next() }).ready;
expect((await disabled.request(`/api/media/${ID}/piano`, { method: 'POST' })).status).toBe(503);
expect((await disabled.request('/api/media/bad/piano')).status).toBe(400);
});

View File

@@ -47,6 +47,9 @@ import * as notesDb from './db.js';
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js'; import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
import { createRemoteHub } from './remote.js'; import { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js'; import { createPartyHub } from './party.js';
import { registerMediaMetaRoutes } from './media-meta.js';
import * as videoCatalog from './video-catalog.js';
import { registerPianoRoutes } from './piano.js';
import { registerUploadRoutes } from './uploads.js'; import { registerUploadRoutes } from './uploads.js';
import { admitFile } from './p2p-admit.js'; import { admitFile } from './p2p-admit.js';
import { P2P } from './p2p-config.js'; import { P2P } from './p2p-config.js';
@@ -1618,6 +1621,8 @@ app.get('/api/media/:id/status', async (c) => {
} }
}); });
registerMediaMetaRoutes(app, { db: notesDb, catalog: videoCatalog, media });
// POST /api/media/:id/redownload — the client's "Broken" button. Idempotent: // POST /api/media/:id/redownload — the client's "Broken" button. Idempotent:
// a job already queued/running for this id is just reported back. // a job already queued/running for this id is just reported back.
app.post('/api/media/:id/redownload', async (c) => { app.post('/api/media/:id/redownload', async (c) => {
@@ -2338,6 +2343,8 @@ const notes = registerNoteRoutes(app, {
// ============================================================================ // ============================================================================
// Uploads — the server's own searchable media library (see uploads.js) // Uploads — the server's own searchable media library (see uploads.js)
// ============================================================================ // ============================================================================
registerPianoRoutes(app, { db: notesDb, adminAuth: notes.adminAuth, enabled: process.env.PIANO_WORKER_ENABLED === '1', workerToken: process.env.PIANO_WORKER_TOKEN || '' });
const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads'); const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads');
const uploads = registerUploadRoutes(app, { const uploads = registerUploadRoutes(app, {
db: notesDb, db: notesDb,

View File

@@ -1,8 +1,9 @@
// One catalog for every discovery source. Media-file caching is independent. // One catalog for every discovery source. Media-file caching is independent.
import { db } from './db.js'; import { db } from './db.js';
import { mergeMissing } from './media-meta-core.js';
const VIDEO_ID = /^[\w-]{11}$/; const VIDEO_ID = /^[\w-]{11}$/;
const SOURCES = new Set(['search', 'search-cache', 'client-search', 'channel', 'streams', 'playlist', 'profile', 'sync', 'related', 'backfill', 'collector']); const SOURCES = new Set(['search', 'search-cache', 'client-search', 'channel', 'streams', 'playlist', 'profile', 'sync', 'related', 'backfill', 'collector', 'device-play']);
const text = (v, n = 300) => typeof v === 'string' ? v.trim().slice(0, n) : ''; const text = (v, n = 300) => typeof v === 'string' ? v.trim().slice(0, n) : '';
const positive = v => Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0; const positive = v => Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0;
const canonicalThumb = id => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; const canonicalThumb = id => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
@@ -48,13 +49,13 @@ export function extractCards(value, limit = 5000) {
let ingestion = Promise.resolve(); let ingestion = Promise.resolve();
let writes = 0; let writes = 0;
export function ingest(cards, source = 'search') { export function ingest(cards, source = 'search', { fillMissing = false } = {}) {
const task = ingestion.then(() => ingestBatch(cards, source)); const task = ingestion.then(() => ingestBatch(cards, source, fillMissing));
ingestion = task.catch(() => {}); ingestion = task.catch(() => {});
return task; return task;
} }
async function ingestBatch(cards, source) { async function ingestBatch(cards, source, fillMissing) {
source = SOURCES.has(source) ? source : 'playlist'; source = SOURCES.has(source) ? source : 'playlist';
const unique = extractCards(cards); const unique = extractCards(cards);
const now = Date.now(); const now = Date.now();
@@ -65,7 +66,7 @@ async function ingestBatch(cards, source) {
const old = new Map(existing.rows.map(r => [r.id, JSON.parse(r.card)])); const old = new Map(existing.rows.map(r => [r.id, JSON.parse(r.card)]));
await db.batch(batch.flatMap(c => { await db.batch(batch.flatMap(c => {
// Sparse playlist cards must not erase richer extractor metadata. // Sparse playlist cards must not erase richer extractor metadata.
const merged = { ...(old.get(c.id) || {}), ...Object.fromEntries(Object.entries(c).filter(([, v]) => v !== '' && v !== 0 && (!Array.isArray(v) || v.length))) }; const merged = fillMissing ? mergeMissing(old.get(c.id) || {}, c) : { ...(old.get(c.id) || {}), ...Object.fromEntries(Object.entries(c).filter(([, v]) => v !== '' && v !== 0 && (!Array.isArray(v) || v.length))) };
return [{ sql: `INSERT INTO video_meta (id,card,hay,seen,updated_at) VALUES (?,?,?,1,?) return [{ sql: `INSERT INTO video_meta (id,card,hay,seen,updated_at) VALUES (?,?,?,1,?)
ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=seen+1,updated_at=excluded.updated_at`, ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=seen+1,updated_at=excluded.updated_at`,
args: [c.id, JSON.stringify(merged), `${merged.title} ${merged.channel || ''} ${(merged.tags || []).join(' ')}`.toLowerCase(), now] }, args: [c.id, JSON.stringify(merged), `${merged.title} ${merged.channel || ''} ${(merged.tags || []).join(' ')}`.toLowerCase(), now] },

View File

@@ -0,0 +1,87 @@
const {test,expect}=require('@playwright/test');
const {differentPixels}=require('./helpers/png-diff');
const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture');
const capture=process.env.YTP_GLASS_CAPTURE;
async function fixture(page,layout,state){
await openClassic(page,{populated:true,settings:{autoPreload:false}});
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'});
await page.evaluate(({layout,state})=>{
data.settings.layout=layout;applyAppearance();SaveQueue.resumeAll=async()=>{};
API.cacheList=async()=>({ok:true,items:data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})),total:629145600});
if(state==='downloads'){
const videos=data.playlists[0].videos.slice(0,4);downloading.clear();downloadMeta.clear();cachedIds.clear();
localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:['active','queued','paused','failed'][i],error:i===3?'Connection lost':''}]))));
videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{received:10485760,total:104857600});});
}
if(state==='search'){searchResults=data.playlists[0].videos;searchAttempted=true;}
view=state==='playlist'?{type:state,id:'studio'}:{type:state};render();
},{layout,state});
if(state==='playing'){await showPlayingFixture(page);await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});}
await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
await page.locator('#listPane').scrollIntoViewIfNeeded();
if(state==='saved')await expect(page.locator('.saved-card')).toHaveCount(3);
if(state==='settings')await expect(page.locator('.settings-section-row').first()).toBeVisible();
}
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['search','queue','history','playlist','saved','downloads','settings'])test(`${layout} ${state} ${width}`,async({page})=>{
await page.setViewportSize({width,height:900});
let baseline, baselineStyles;
const styles = target => target.locator('.nav-item,.bottom-nav-btn,.card-menu,.card-del,.saved-file-action,.dl-act,.settings-section-row').evaluateAll(nodes=>nodes.map(el=>{const s=getComputedStyle(el),r=el.getBoundingClientRect();return [el.className,r.x,r.y,r.width,r.height,s.color,s.backgroundColor,s.borderRadius,s.fontSize,s.display];}));
if(!capture&&layout==='classic'){
const prior=await page.context().newPage();await prior.setViewportSize({width,height:900});
await prior.route('**/theme-glass-controls.css',r=>r.fulfill({contentType:'text/css',body:''}));
await prior.route('**/glass-controls.js',r=>r.fulfill({contentType:'application/javascript',body:''}));
await fixture(prior,layout,state);baselineStyles=await styles(prior);baseline=await prior.screenshot({path:`/tmp/glass-polish-before-${layout}-${width}-${state}.png`});await prior.close();
}
await fixture(page,layout,state);
const path=`/tmp/glass-polish-${capture||'after'}-${layout}-${width}-${state}.png`;
const screenshot=await page.screenshot({path});
if(!capture&&layout==='classic'){expect(await styles(page)).toEqual(baselineStyles);expect(differentPixels(screenshot,baseline)).toBeLessThanOrEqual(16);}
if(!capture&&layout==='glass-stage'){
const svg=page.locator('#bottomNav .classic-nav-icon').first();expect(await svg.evaluate(el=>getComputedStyle(el).display)).not.toBe('none');
const stroke=await svg.evaluate(el=>getComputedStyle(el).strokeWidth);expect(stroke).toBe('1.9px');
const controls=await page.locator('.saved-file-action,.dl-act,.settings-section-row,.card-menu').evaluateAll(nodes=>nodes.filter(n=>n.getBoundingClientRect().width>0).map(n=>{const r=n.getBoundingClientRect();return [r.width,r.height];}));
expect(controls.every(([w,h])=>w>=44&&h>=44)).toBe(true);
}
});
test('Now Playing Glass reference',async({page})=>{
await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','playing');await page.locator('#nowPlayingMeta').scrollIntoViewIfNeeded();
await page.screenshot({path:`/tmp/glass-polish-${capture||'after'}-reference.png`});
});
test('Glass navigation, notes and mini-player controls keep labels and round-trip cleanly',async({page})=>{
await page.setViewportSize({width:390,height:900});await openClassic(page,{populated:true,settings:{autoPreload:false}});await showPlayingFixture(page);
await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}'});
await page.evaluate(async()=>{await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
const markup = () => page.locator('#listPane').evaluate(el=>{const copy=el.cloneNode(true);copy.querySelectorAll('[style=""]').forEach(n=>n.removeAttribute('style'));return copy.outerHTML;});
const before=await markup();
const miniClass=await page.locator('#miniBar').getAttribute('class');
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
await expect(page.locator('.card-menu .glass-control-icon').first()).toBeAttached();
// Isolated installed-navigation fixture: the real app shows this bar in
// standalone portrait mode; ordinary Chromium isn't an installed PWA.
await page.addStyleTag({content:'html[data-layout="glass-stage"] #bottomNav{display:flex}html[data-layout="glass-stage"] #miniBar{display:block}'});
await page.locator('#miniBar').evaluate(el=>el.classList.remove('hidden'));
await expect(page.locator('#miniPlayBtn svg')).toBeVisible();
await expect(page.locator('#miniPlayBtn')).toHaveAttribute('aria-label',/Play|Pause/);
await expect(page.locator('#bottomNav [aria-label="Queue"] svg')).toBeVisible();
expect(await page.locator('#bottomNav [aria-label="Downloads"] .bottom-nav-label').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true);
await page.screenshot({path:'/tmp/glass-polish-navigation-notes-mini.png'});
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
await expect(page.locator('.glass-control-icon')).toHaveCount(0);
await page.locator('#miniBar').evaluate((el,className)=>{el.className=className;},miniClass);
expect(await markup()).toBe(before);
});
test('Glass icons preserve button commands and keyboard focus',async({page})=>{
await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','queue');
const action=page.locator('.card-menu').first();await action.focus();
expect(await action.evaluate(el=>getComputedStyle(el).outlineStyle)).toBe('solid');
await action.press('Enter');await expect(page.locator('#modal')).toBeVisible();
});
test('Glass settings final row is reachable and file actions are not covered',async({page})=>{
await page.setViewportSize({width:390,height:900});await fixture(page,'glass-stage','saved');
const covered=await page.locator('.saved-file-action').evaluateAll(nodes=>nodes.some(el=>{const r=el.getBoundingClientRect();if(r.bottom>innerHeight)return false;const target=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return !el.contains(target);}));expect(covered).toBe(false);
await page.setViewportSize({width:1440,height:900});await fixture(page,'glass-stage','settings');
const last=page.locator('.settings-section-row').last();await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
await page.screenshot({path:'/tmp/glass-polish-settings-end.png'});
});

27
tests/helpers/png-diff.js Normal file
View File

@@ -0,0 +1,27 @@
// Lossless Chromium screenshots: tolerate a few rasterization pixels while
// keeping strict geometry/style comparisons in the calling UI test.
const { inflateSync } = require('node:zlib');
function decode(buffer) {
let width,height,channels;const chunks=[];
for(let offset=8;offset<buffer.length;){
const length=buffer.readUInt32BE(offset),type=buffer.toString('ascii',offset+4,offset+8),data=buffer.subarray(offset+8,offset+8+length);offset+=length+12;
if(type==='IHDR'){width=data.readUInt32BE(0);height=data.readUInt32BE(4);channels=data[9]===6?4:3;if(data[8]!==8||![2,6].includes(data[9])||data[12])throw new Error('Expected an 8-bit RGB Chromium PNG');}
if(type==='IDAT')chunks.push(data);
}
const raw=inflateSync(Buffer.concat(chunks)),stride=width*channels,pixels=Buffer.alloc(stride*height);let position=0;
const paeth=(a,b,c)=>{const p=a+b-c,da=Math.abs(p-a),db=Math.abs(p-b),dc=Math.abs(p-c);return da<=db&&da<=dc?a:db<=dc?b:c;};
for(let y=0;y<height;y++){
const filter=raw[position++];if(filter>4)throw new Error('Unexpected PNG filter');
for(let x=0;x<stride;x++){
const index=y*stride+x,a=x>=channels?pixels[index-channels]:0,b=y?pixels[index-stride]:0,c=y&&x>=channels?pixels[index-stride-channels]:0;
pixels[index]=(raw[position++]+[0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter])&255;
}
}
return {width,height,channels,pixels};
}
function differentPixels(first,second){
const a=decode(first),b=decode(second);if(a.width!==b.width||a.height!==b.height||a.channels!==b.channels)return Infinity;
let count=0;for(let i=0;i<a.pixels.length;i+=a.channels)if(!a.pixels.subarray(i,i+a.channels).equals(b.pixels.subarray(i,i+a.channels)))count++;
return count;
}
module.exports={differentPixels};

View File

@@ -0,0 +1,75 @@
const { test, expect } = require('@playwright/test');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
for (const layout of ['classic', 'glass-stage']) {
for (const width of [390, 1440]) {
for (const route of ['mini player', 'close', 'player jump', 'browser back', 'track reveal'].filter(route => width === 390 || route !== 'player jump')) {
test(`${layout} ${width}: ${route} restores the playing list`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await openClassic(page, { populated: true, settings: { autoPreload: false } });
await showPlayingFixture(page);
await page.evaluate(layout => {
document.documentElement.dataset.layout = layout;
for (const type of ['search', 'history', 'saved', 'downloads', 'channel', 'settings', 'search']) {
view = { type };
if (type === 'search') setHomeOverPlayer(true);
render();
}
showMiniBar();
}, layout);
// Chromium is not an installed phone PWA. Expose its mini-player
// chrome while exercising the actual click listeners (no forced click).
await page.addStyleTag({ content: '#miniBar { display:flex !important; }' });
if (route === 'close') await page.locator('#miniCloseBtn').click();
else if (route === 'track reveal') {
// User-picked tracks/deep links share this reveal hook after loading;
// avoid testing network/media decoding with synthetic fixture ids.
await page.evaluate(() => scrollPlayerIntoViewPortrait());
} else {
await page.locator('#miniTitle').click();
if (route === 'player jump') {
await page.evaluate(() => { view = { type: 'history' }; render(); });
await page.locator('#sectionRail [data-sec="player"]').click();
} else if (route === 'browser back') {
await page.evaluate(() => { view = { type: 'history' }; render(); });
await page.goBack();
await expect(page.locator('#listTitle')).toHaveText('Studio sessions');
await page.goBack();
await expect(page.locator('#playerPane')).toHaveClass(/home-over/);
await page.goForward();
}
}
await expect(page.locator('#upnext')).toBeVisible();
await expect(page.locator('#upnextList .upnext-item')).toHaveCount(11);
await expect(page.locator('#listPane')).toBeVisible();
await expect(page.locator('#listTitle')).toHaveText('Studio sessions');
await expect(page.locator('#cards .card')).toHaveCount(12);
expect(await page.evaluate(() => ({ source: queueSource, index: queueIndex, size: queue.length, id: current.meta.id })))
.toEqual({ source: 'playlist:studio', index: 0, size: 12, id: 'preview0000' });
});
}
}
test(`${layout}: missing source playlist falls back to the live queue`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openClassic(page, { populated: true, settings: { autoPreload: false } });
await showPlayingFixture(page);
await page.evaluate(layout => {
document.documentElement.dataset.layout = layout;
data.playlists = [];
data.queue = [];
view = { type: 'downloads' }; render();
scrollPlayerIntoViewPortrait();
}, layout);
await expect(page.locator('#listTitle')).toHaveText('Now playing');
await expect(page.locator('#cards .card')).toHaveCount(12);
await expect(page.locator('#upnextList .upnext-item')).toHaveCount(11);
const historyLength = await page.evaluate(() => history.length);
// Re-entry must repair stale DOM even when home-over is already false.
await page.evaluate(() => { document.getElementById('upnextList').innerHTML = ''; scrollPlayerIntoViewPortrait(); });
await expect(page.locator('#upnextList .upnext-item')).toHaveCount(11);
expect(await page.evaluate(() => history.length)).toBe(historyLength);
await page.evaluate(() => { queueIndex = queue.length - 1; scrollPlayerIntoViewPortrait(); });
await expect(page.locator('#upnext')).toBeHidden();
await expect(page.locator('#cards .card')).toHaveCount(12);
});
}

View File

@@ -0,0 +1,73 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
const capture = process.env.YTP_OFFLINE_CAPTURE;
async function fixture(page, layout, state) {
await openClassic(page);
await page.evaluate(({layout,state}) => {
data.playlists = [{id:'studio', name:'Studio', videos:Array.from({length:4},(_,i)=>({id:'preview'+String(i).padStart(4,'0'),title:i===2?'An extended conversation about making music and finding a little room to listen':'Studio session '+i,channel:'Preview',thumbnail:'/fixture-cover.svg'}))}];
cachedIds.clear(); downloading.clear(); downloadMeta.clear(); SaveQueue.resumeAll=async()=>{};
data.settings.layout = layout; applyAppearance();
API.cacheList = async () => ({ ok:true, items: state==='downloads-empty' ? [] : data.playlists[0].videos.slice(0,3).map((v,i)=>({id:v.id,size:104857600*(i+1)})), total:state==='downloads-empty'?0:629145600 });
if(state==='downloads-populated') {
const videos=data.playlists[0].videos.slice(0,4); const statuses=['active','queued','paused','failed'];
localStorage.setItem('ytpSaveQueue',JSON.stringify(Object.fromEntries(videos.map((v,i)=>[v.id,{...v,at:Date.now(),status:statuses[i],error:i===3?'Connection refused':''}]))));
videos.slice(0,2).forEach((v,i)=>{downloading.add(v.id);downloadMeta.set(v.id,v);SaveQueue.progress(v.id,i?{phase:'waiting'}:{phase:'downloading',received:10485760,total:104857600});});
}
view={type:state.startsWith('downloads')?'downloads':'saved'};render();
},{layout,state});
}
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])for(const state of ['downloads-empty','downloads-populated','saved-populated'])test(`${layout} ${state} at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:900});await fixture(page,layout,state);
if(state==='saved-populated')await expect(page.locator('.saved-card')).toHaveCount(3);
if(state==='downloads-populated')await expect(page.locator('.dl-row')).toHaveCount(4);
await page.locator('#listPane').scrollIntoViewIfNeeded();
if(state==='downloads-empty') {await expect(page.locator('#listFilterBar')).toBeHidden();await expect(page.getByRole('button',{name:'View saved videos',exact:true})).toBeVisible();}
if(state==='downloads-populated') {await expect(page.getByRole('button',{name:/^Pause /})).toHaveCount(2);await expect(page.getByRole('button',{name:/^Retry /})).toHaveCount(1);}
await page.locator('.card').evaluateAll(async nodes => { await Promise.all(nodes.flatMap(node => node.getAnimations().map(animation => animation.finished.catch(() => {})))); });
if(capture)await page.screenshot({path:`/tmp/ytplayer-offline-${capture}-${layout}-${width}-${state}.png`});
});
test('download cancellation waits for confirmation and filtering keeps queue intact', async({page})=>{
await fixture(page,'classic','downloads-populated');
await page.locator('.dl-row').first().getByRole('button',{name:/^Cancel /}).click();
await expect(page.getByRole('button',{name:'Cancel save',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Keep',exact:true}).click();
await expect(page.locator('.dl-row')).toHaveCount(4);
await page.locator('#listFilterInput').fill('extended');
await expect(page.locator('.dl-row')).toHaveCount(1);
expect(await page.evaluate(()=>SaveQueue.pending().length)).toBe(4);
});
for(const layout of ['classic','glass-stage'])for(const width of [390,1440])test(`${layout} Saved actions do not overlap at ${width}px`,async({page})=>{
await page.setViewportSize({width,height:900});await fixture(page,layout,'saved-populated');
await expect(page.locator('.saved-file-action')).toHaveCount(6);
const bounds=await page.locator('.saved-file-actions').first().locator('button').evaluateAll(nodes=>nodes.map(n=>{const r=n.getBoundingClientRect();return {width:r.width,height:r.height,left:r.left,right:r.right,top:r.top};}));
expect(bounds[0].width).toBeGreaterThanOrEqual(44);expect(bounds[0].height).toBeGreaterThanOrEqual(44);
expect(bounds[1].left).toBeGreaterThanOrEqual(bounds[0].right);expect(bounds[1].top).toBe(bounds[0].top);
await expect(page.locator('.saved-storage-summary').getByRole('button',{name:'Clear all saved videos'})).toBeVisible();
});
test('saved deletion is confirmed and a storage failure retains the file',async({page})=>{
await fixture(page,'classic','saved-populated');
await page.evaluate(()=>{API.cacheDelete=async()=>({ok:false,error:'Disk busy'});});
await page.locator('.saved-file-action.danger').first().click();
await expect(page.getByRole('button',{name:'Delete saved file',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Cancel',exact:true}).click();
await expect(page.locator('.saved-card')).toHaveCount(3);
await page.locator('.saved-file-action.danger').first().click();
await page.getByRole('button',{name:'Delete saved file',exact:true}).click();
await expect(page.locator('.saved-card')).toHaveCount(3);
await page.getByRole('button',{name:'Clear all saved videos'}).click();
await expect(page.getByRole('button',{name:'Clear all',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Cancel',exact:true}).click();
await page.locator('#listFilterInput').fill('extended');
await expect(page.locator('.saved-card')).toHaveCount(1);
});
test('actual device playback reports metadata once without requesting streams',async({page})=>{
await openClassic(page);
const reports=[];await page.route('**/api/media/*/meta',route=>{reports.push(route.request().postDataJSON());return route.fulfill({contentType:'application/json',body:'{"ok":true,"known":false,"cache":"queued"}'});});
await page.evaluate(()=>{
current={meta:{id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist',duration:240},qualities:[]};
Player.master=els.audio;Player.secondary=null;
els.audio.dispatchEvent(new Event('playing'));els.audio.dispatchEvent(new Event('playing'));
});
await expect.poll(()=>reports.length).toBe(1);expect(reports[0]).toMatchObject({id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist'});
});

View File

@@ -0,0 +1,56 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
test('Basic Pitch is lazy and transcribes a short synthetic WAV', async ({ page }) => {
test.setTimeout(120000);
page.on('requestfailed', (req) =>
console.log('Engine request failed:', req.url(), req.failure()?.errorText),
);
const requests = [];
page.on('request', (req) => {
if (/esm\.sh|jsdelivr.*basic-pitch/.test(req.url())) requests.push(req.url());
});
await openClassic(page);
expect(requests).toEqual([]);
const result = await page.evaluate(async () => {
const rate = 22050,
samples = rate * 2,
buffer = new ArrayBuffer(44 + samples * 2),
view = new DataView(buffer);
const text = (offset, value) => {
[...value].forEach((c, i) => view.setUint8(offset + i, c.charCodeAt(0)));
};
text(0, 'RIFF');
view.setUint32(4, 36 + samples * 2, true);
text(8, 'WAVE');
text(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
text(36, 'data');
view.setUint32(40, samples * 2, true);
for (let i = 0; i < samples; i++)
view.setInt16(44 + i * 2, Math.sin((2 * Math.PI * 440 * i) / rate) * 16000, true);
const engine = await import('/piano-engine.mjs');
const result = await engine.transcribe(buffer.slice(0), () => {});
const tf = await import('https://esm.sh/@tensorflow/tfjs@4.22.0');
const before = tf.memory().numTensors;
const second = await engine.transcribe(buffer.slice(0), () => {});
return {
engine: result.engine,
notes: PianoCore.fromBasicPitch(result.notes),
secondNotes: second.notes.length,
before,
after: tf.memory().numTensors,
};
});
expect(result.engine).toMatch(/basic-pitch-1.0.1\/(webgpu|webgl|cpu)/);
expect(requests.length).toBeGreaterThan(0);
expect(result.notes.length).toBeGreaterThan(0);
expect(result.notes.some((n) => n.pitch === 69)).toBe(true);
expect(result.secondNotes).toBeGreaterThan(0);
expect(result.after).toBe(result.before);
});

View File

@@ -0,0 +1,83 @@
const { test, expect } = require('@playwright/test');
const { openClassic } = require('./helpers/classic-fixture');
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) {
test(`${layout} settings navigation at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await openClassic(page);
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); view = { type: 'settings' }; render(); }, layout);
await expect(page.locator('.settings-section-row').first()).toBeVisible();
await expect(page.locator('#setQuality')).toHaveCount(1);
if (width === 390) await expect(page.locator('#setQuality')).toBeHidden();
await page.locator('.settings-search input').fill('default quality');
await page.locator('.settings-results button').first().click();
await expect(page.locator('#setQuality')).toBeVisible();
await page.locator('#setQuality').selectOption('720p');
expect(await page.evaluate(() => data.settings.quality)).toBe('720p');
if (width === 390) { await page.goBack(); await expect(page.locator('.settings-overview')).toBeVisible(); await expect(page.locator('#setQuality')).toBeHidden(); }
await page.locator('.settings-section-row[data-section="appearance"]').click();
await expect(page.locator('#setTheme')).toBeVisible();
await page.locator('#setTheme').selectOption('light');
expect(await page.evaluate(() => data.settings.theme)).toBe('light');
await expect(page.locator('#setQuality')).toHaveValue('720p');
expect(await page.evaluate(() => sessionStorage.getItem('ytpSettingsSection'))).toBe('appearance');
const overflow = await page.locator('.settings-browser').evaluate(el => el.scrollWidth > el.clientWidth + 1); expect(overflow).toBe(false);
});
}
test('floating lyrics move the real view and restore it on close', async ({ page }) => {
await page.addInitScript(() => { window.documentPictureInPicture = { requestWindow: async () => window.open('about:blank', 'lyrics-test', 'width=420,height=500') }; });
await openClassic(page);
const popupPromise = page.waitForEvent('popup');
await page.locator('#stageLyricsAuto').evaluate(el => el.nextElementSibling.click());
const popup = await popupPromise;
await expect(popup.locator('#stageLyrics')).toBeVisible();
await expect(popup.getByRole('button', { name: 'Play / Pause', exact: true })).toBeVisible();
await expect(page.locator('#stageLyrics')).toHaveCount(0);
await popup.close(); await expect(page.locator('#stageLyrics')).toHaveCount(1);
});
test('OBS lower third has a transparent background and renders two cues', async ({ page }) => {
await page.route('**/api/**', route => route.fulfill({ contentType: 'application/json', body: '{"ok":false}' }));
await page.goto('/?overlay=lowerthird&code=123456');
await page.evaluate(() => LowerThird.paint([{ kind: 'line', text: 'Current lyric' }, { kind: 'line', text: 'Next lyric' }], 0));
await expect(page.locator('.obs-current')).toHaveText('Current lyric'); await expect(page.locator('.obs-next')).toHaveText('Next lyric');
expect(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await expect(page.locator('#searchInput')).toBeHidden();
});
test('setlist import confirms library matches and retains key tags', async ({ page }) => {
await openClassic(page, { populated: true }); await page.evaluate(() => SetlistImport.open());
await page.getByLabel('Setlist text', { exact: true }).fill('1. A quiet morning — live studio session [E]');
await page.getByRole('button', { name: 'Find matches', exact: true }).click();
await expect(page.getByLabel('Match A quiet morning — live studio session', { exact: true })).toHaveValue('preview0000');
await page.getByRole('button', { name: 'Create setlist', exact: true }).click();
expect(await page.evaluate(() => data.playlists.at(-1).videos[0].tags)).toContain('Key E');
});
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) test(`${layout} Car mode at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await openClassic(page); await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); CarMode.open(); }, layout);
await expect(page.getByRole('dialog', { name: 'Car mode' })).toBeVisible();
expect(await page.locator('.car-controls button').first().evaluate(el => el.getBoundingClientRect().height)).toBeGreaterThanOrEqual(100);
expect(await page.locator('.app').evaluate(el => el.inert)).toBe(true);
await page.getByRole('button', { name: 'Exit Car mode' }).click(); await expect(page.locator('.car-mode')).toHaveCount(0); expect(await page.locator('.app').evaluate(el => el.inert)).toBe(false);
});
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) test(`${layout} ten-band EQ at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 }); await openClassic(page, { settings: { eq: { preset: 'custom', g: [6,2,-2,4,0] } } });
await page.evaluate(layout => { data.settings.layout = layout; applyAppearance(); EQ.open(); }, layout);
await expect(page.locator('.eq-band input')).toHaveCount(10);
expect(await page.evaluate(() => data.settings.eq.g.length)).toBe(10);
await page.getByRole('button', { name: 'Vocal clarity', exact: true }).click();
expect(await page.evaluate(() => data.settings.eq.g[7])).toBe(4);
expect(await page.locator('.eq-panel').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
for (const layout of ['classic', 'glass-stage']) test(`${layout} secondary lyrics in stage and Service mode`, async ({ page }) => {
await openClassic(page); await page.route('**/api/notes/0gfX0dFLaBc', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok:true, lyrics:{rev:1,data:{offset:0,lines:[{t:0,text:'主你的恩典',secondary:'Your grace is enough',phonetic:'zhǔ nǐ de ēndiǎn',kind:'line'}]}}, chapters:null }) }));
await page.evaluate(async layout => { data.settings.layout = layout; applyAppearance(); current = {meta:{id:'0gfX0dFLaBc',title:'Fixture'}}; await Notes.load(current.meta); Notes.stageLyrics.set(true); }, layout);
await expect(page.locator('#stageLyricsList .lyric-secondary').first()).toHaveText('Your grace is enough');
await page.evaluate(() => { $('serviceMode').classList.remove('hidden'); data.settings.serviceLyrics = true; Notes.serviceRefresh(); });
await expect(page.locator('#serviceLyricsList .lyric-phonetic')).toHaveText('zhǔ nǐ de ēndiǎn');
expect(await page.locator('#serviceLyricsList .lyric-secondary').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) < parseFloat(getComputedStyle(el.parentElement).fontSize))).toBe(true);
});
for (const layout of ['classic', 'glass-stage']) for (const width of [390,1440]) test(`${layout} piano settings stay lazy at ${width}px`, async ({ page }) => {
const models=[];page.on('request',req=>{if(/esm\.sh|jsdelivr.*basic-pitch/.test(req.url()))models.push(req.url());});
await page.setViewportSize({width,height:900});await openClassic(page);await page.evaluate(layout=>{data.settings.layout=layout;applyAppearance();current={meta:{id:'0gfX0dFLaBc',title:'Piano fixture',duration:200}};view={type:'settings'};render();},layout);
await page.locator('[data-section="piano"]').click();await expect(page.getByRole('button',{name:'Transcribe in browser',exact:true})).toBeDisabled();
await page.locator('#settings-section-piano input[type="checkbox"]').check();await expect(page.getByRole('button',{name:'Transcribe in browser',exact:true})).toBeEnabled();
await page.getByRole('button',{name:'Add current 8 s to practice',exact:true}).click();await expect(page.locator('.piano-practice button').first()).toHaveText('Loop 0.0–8.0 s');
expect(models).toEqual([]);expect(await page.locator('#settings-section-piano').evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true);
});