- 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".
iOS stops Web Audio when the screen locks, so live processing can't reach a
locked phone. For saved songs, EqRender renders the same chain as the live EQ
(5 bands -> level gain -> limiter) offline into a 32 kHz stereo WAV in OPFS
(eq-renders/<id>.wav, written by the OPFS worker's new write op), one song at a
time in the background. Playback then takes the picture from the saved video
(muted) and the sound from that copy in dual mode — plain media playback, so it
continues with the screen locked; live gains are zeroed so nothing is applied
twice. Copies are re-rendered when the EQ curve or a song's loudness changes
and removed when nothing needs applying.
- Level volume is now on by default everywhere (on iPhone it reaches saved
songs through these copies).
- New iPhone-only Settings -> Playback -> Live sound processing (off by
default): live Web Audio EQ/levelling for streamed songs, stops when locked.
- The EQ dialog works on iPhone and explains where the curve applies.
Settings → Offline cache lists every saved video; it now sits in a collapsible
'Saved videos' section showing the count and total size, closed by default,
remembering how it was left.
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.
The CH badge by the scrubber is now a button opening a list of every chapter
(number, title, start time); the playing one is highlighted and scrolled into
view, a tap seeks there (and resumes if paused). Works with shared chapters and
YouTube's.
- 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).
The raw browser file inputs (grey 'Choose File', truncated 'No fi...osen')
sat beside styled fields of other heights with Upload orphaned on its own row.
Now a drop zone that names the chosen file (size, type) and accepts drops, a
labelled Title / Artist / Cover row with a cover thumbnail preview, and a
clear 'choose a file first' message. Same IDs and upload code; stacks on phones.
They relied on the hidden attribute, which browsers do not reliably honour on
SVG elements, so in Glass Stage each nav item drew a full-size black icon.
- 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.
- Channel pages join the select-mode views: tap Select in the filter row, pick
videos (or Select all — respects the filter), then + Queue / + Playlist.
Delete is hidden there (nothing to delete on a channel).
- --bottom-chrome (the fixed bottom nav + mini player height) is now measured
whenever the mini player shows/hides and on resize/rotation, not only while
the section rail scrolls, and the list pane pads its end by it, so the last
card scrolls fully clear with or without the mini player.
Stray pinches and double-taps kept zooming the installed app. On iOS only the
viewport gets maximum-scale=1/user-scalable=no, Safari's gesture events are
cancelled and the page uses touch-action: manipulation. The player's own pinch
zoom (pointer events) still works.
Channels: one loader tries, in order, the Videos/Live/Shorts tabs (normal
channels), the uploads playlist (Topic channels), the Releases/Playlists tabs
expanded into their tracks (artist/music channels whose only content is albums,
e.g. Cathedral of Praise Worship), then the home page; bare names are resolved
to an id first. Nothing public → ok with a message, never a raw yt-dlp error.
Saves: SaveSlots caps concurrent offline saves (Settings → Offline cache →
Videos saved at once, 1–8, default 4); queued saves show "Waiting for a free
slot", and resumed saves now run up to the limit instead of one at a time.
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).
Shown as soon as an update is offered, so Later, a backdrop tap or a failed
download all leave a one-tap way to update; it targets the newest build and
disappears while updating. The search form and box now shrink so the icon
never pushes Search off a phone screen.
In service mode's Video view the overlay lives inside the player pane, which
the home-over rule hid, while service mode kept the bottom bar and mini bar
hidden. Going home now leaves service mode first, and the rule spares the
service overlay.
- fitLyricLines measures text on a canvas with the line's own font and writes
every --fit after measuring (it used an in-list DOM probe and wrote inside
the loop, re-laying-out the whole list per line). 1200 lines: 600 ms -> 28 ms;
sizes match the old method within 1 px. Service mode on long sermons with
hundreds of transcribed lines opened slowly enough on phones to look broken.
- Bottom-nav Search while something plays shows the home screen (playlist
board / hero) over the player pane; the media keeps playing unseen and the
mini bar brings the player back (its close button too, so there is always a
way back). Starting another video restores the player.
Every card of a video carries .downloading while it saves, so the progress
painter rewrote their channel line and its final repaint left 'Saving for
offline…' on the card after the save had finished.
Server
- /api/download/:id answers Range with a strong ETag ("<id>.<gen>") and honours
If-Range; a stale partial gets the whole current file (streamed, so Bun does
not re-apply the Range itself). Uploads get the same treatment.
- GET /api/download/:id/prepare never blocks: ready {gen,size,sha256,etag,ext},
working (server still fetching), legacy (HEVC / too long / cache offline),
failed. Recently refused prepares are remembered for 10 minutes.
Browser
- The OPFS worker saves in 8 MiB ranges, writes at the byte offset, flushes
each chunk, retries each chunk 6 times with backoff (30 s idle timeout) and
keeps the .part plus a .part.json sidecar naming the server copy it belongs
to. A changed copy restarts cleanly; the finished file is hashed once and
checked against the server's SHA-256.
- SaveQueue remembers unfinished saves and resumes them on start, online,
return to the foreground and every 2 minutes while visible; one at a time.
- Downloads shows live MB progress, 'Preparing on server', 'Verifying', and
paused saves with Resume and Cancel (confirmed).
- listVideos ignores the sidecars; new listPartials/discardPartial helpers.
Verified in Chromium through a connection-dropping proxy: paused at 8 MiB,
auto-resumed after a reload from byte 8388608, final SHA-256 matched.
- UPLOAD_DIR moves to the USB drive; reads fall back to UPLOAD_BACKUP_DIR on the
ytplayer-data volume, and while the drive is offline new uploads land there
- scripts/ops/uploads-backup.sh (homelab cron, 03:30) copies both ways, never
deletes; the server removes a deleted upload from both places
- /api/channel resolves a bare channel name ('Artist - Topic') to its id via a
search, and the client falls back to the channel name for old saved entries
- MEDIA_DIR moves to /mnt/data/ytplayer (bind-mounted at /app/bulk); DB, lyrics,
notes and uploads stay on the ytplayer-data volume
- MEDIA_VOLUME_MARKER: while the marker on the drive is missing the cache writes,
serves and deletes nothing, so playback streams and an unmounted drive never
fills the disk under its mountpoint or loses its index on a reboot
- Budget 250 GiB, keep 20 GiB free on the drive
- /api/channel falls back to the uploads playlist and the channel home page when
a channel has no Videos tab (auto-generated '- Topic' music channels)
- A single dock button cycles the service view; video is skipped in audio-only mode
- Visualizer reacts to the audio where the EQ graph is available and falls back
to an ambient pulse on iOS so background playback is never routed through Web Audio
- Album art shows the song artwork with the letterbox cropped
- A-/A+ box steps lyrics, title and channel size from -2 to +2 in every theme
- Glass Stage dock is slightly more compact