Queue reviewable Whisper drafts from the song list and lyrics editor. Preserve line breaks within one timed cue across editing, saving, reporting, and service views.
Add storage and listening analytics with a durable metadata collector, related-search depth, video limits, thumbnail storage, and a browsable metadata library.
- frontend/wasm/loudness.c → loudness-wasm.js (1.5 KB, base64-embedded; built by
scripts/build-loudness-wasm.sh with clang or Zig's clang): K-weighting + 100 ms
energy steps. loudness.js does the 400 ms gating (abs -70 LUFS, rel -10 LU)
with an identical JS fallback. EBU Tech 3341 sine cases measure -23.0/-33.0
on both engines; a real track reads -21.7 vs ffmpeg ebur128 -21.8.
- The device measures from audio it already has (saved copy or the server
cache's m4a sidecar, songs up to 12 min, decoded natively at 22.05 kHz) and
reports the number to /api/loudness, so each song is measured once for
everyone; the server stores numbers only, no audio processing.
- The EQ graph gains a level stage (gain -> limiter) that eases each track
toward -14 LUFS (-12..+9 dB). Settings -> Playback -> Level volume: on by
default on Android/desktop, opt-in on iPhone (Web Audio stops sound when the
screen locks there).
- Server: chapters come from yt-dlp's `chapters` (creator chapters and
description timestamps) whenever a video is resolved, kept in a yt_chapters
table; GET /api/chapters?v= serves them (resolving once if needed, 30-day
cache) so server-cached copies get them too. Never fails the player.
- Player: a video's shared chapters win; with none, YouTube's stand in —
listed as "Chapters from YouTube", read-only (no edit/delete; + Chapter
copies them into an editable list). They drive the existing seek-bar ticks
(classic and Glass Stage), the chapter badge and the chapter list, and are
cached offline with the video's notes.
- Keys: [ previous chapter (restarts the current one when >3 s in), ] next.
Profiles without protection behave as before. Protected profiles store only an
argon2id hash; load, save, protection changes and the playlist inbox need the
secret (X-Profile-Secret), wrong guesses are throttled per profile and IP.
Settings: Protect (password or generated key), change/remove, export and import
a credentials file; loading a protected profile prompts for the secret.
- manifest share_target (POST multipart: title/text/url + audio/video files)
and eight shortcuts (Search, Queue, Saved, Downloads, History, Library,
Notes, Settings). /?view=<page> now actually opens that page — the old
shortcuts pointed at it but nothing read it.
- sw.js receives the share: files are parked in the ytp-share-inbox cache (not
a versioned shell cache, so deploys never evict it) and the app is opened;
links/text go to /?shared=. A server POST /share-target fallback covers the
first visit before a worker is in control (links only).
- The app plays a YouTube link found anywhere in the shared text (watch,
youtu.be, shorts, live, embed, music; keeps t=), otherwise searches the text.
/?shared=<link> also works from an iOS Shortcut (iOS has no share target).
- Shared files upload with progress to PUT /api/uploads/shared and join a
"Shared uploads" playlist; network failures stay in the inbox for the next
open, refusals are dropped with the reason.
- The public route is bounded: audio/video only (ffprobe-validated, error text
without server paths), 500 MB per file, 2 GB and 20 a day per device, 50 GB
for all shared uploads, one at a time per device (all env-tunable). Shared
uploads are unlisted: reachable by id, never in anyone else's search.
uploads gains owner + listed columns (idempotent ALTER).