The served Vibe preview was eight items deep, and every one of them cost
a track fetch on each advance while buying nothing but a longer Up next
list. Three is enough to show where the stream is going.
The audio prefetch was the opposite problem: it only began twenty
seconds before the end, so a phone that lost signal in that window
arrived at the handover with nothing buffered. It now starts fifteen
seconds into the current track, which gives the rest of the song to pull
the next one down. Buffering that early means a replan can change the
answer, so the idle element is re-pointed when it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the manifest, icons and service worker that make the app
installable, and offers it as a toast once Chrome says it qualifies.
Declining snoozes the offer for a month; installing ends it.
A waiting service worker never activates on its own. Reloading the page
under a listener to swap in a new build would cut the song they are in
the middle of, so updates land on the next cold start instead. Audio is
kept out of the cache entirely: range requests and multi-megabyte bodies
do not belong in a shell cache. Artwork is cached, and the SPA
navigation fallback denies /api so it cannot swallow the event stream.
Installed on Android the app paints edge to edge, so the transport pads
itself past the gesture bar. MediaSession gains setPositionState, which
is what gives the notification shade a seek bar that moves.
Three things kept the bundle from ever being compressed, each hiding the
next: the nginx image ships with gzip off, gzip_proxied defaults to off
and skips anything carrying a Via header, and gzip_http_version defaults
to 1.1 while the host proxy speaks 1.0. With those fixed and the pages
split per route, the first load goes from 555KB to 60KB of app code plus
a vendor chunk that survives redeploys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The queue panel drove the playback store directly, so its buttons played
locally while another device held the audio. Both control sets now go
through one transport that forwards a press when the audio is elsewhere.
The panel's artwork is capped against viewport height too: at full width
on a phone the square alone pushed Up Next off the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One device holds the audio; the rest watch the same session over an
event stream and act as remotes. Picking a device hands the audio over
at the position the previous one reported, and that device stops.
Also centre the command palette with margins instead of a translate:
animate-rise sets its own transform and dropped the offset on mobile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.
- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
misses the same song reaching us under two Deezer release ids.
Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pass over the whole app against the Ethos laws, then a focused pass on
Vibe with the operator reviewing each change.
Across the app:
- The player bar restores the last track it played, paused at zero, so a
fresh tab opens on where the listener was instead of "nothing playing".
- Track titles link to their album, matching the artist links beside them.
Playback stays on the artwork tile; a title that played was the surprise.
- The search field is bg-bg2. Tailwind cannot alpha-modify these var()
colors, so bg-surface0/70 emitted no rule at all and the input fell back
to the UA's white.
- Row hover is light falling off to the right, not a flat slab.
- The artwork placeholder can drop its note glyph, so TrackRow no longer
layers a play icon on top of one.
Vibe:
- A seeded Vibe plays its seed first. The seed sits in front of the durable
plan without being part of it, so the first advance consumes it locally
and reports no plan feedback.
- The queue drops a second recording of a song it already holds — same
title, different track id, which id-based dedup let through.
- Up next is read from the queue rather than the plan preview, since the
seed is not a plan item.
- The header carries the live profile (energy, discovery, goal) and both
verbs. Keep is gone: letting a track finish already reports `completed`,
which the director weighs the same.
- The aura is one warm diffuse blob in the page background, warm-hued only
and quieter on mobile.
- Compact artwork is 32px. It was h-8 w-8, which this remapped spacing
scale renders as 64px inside a 44px row, and that overflow was the
"stacked" look.
Verified by render at 1440x900 and 390x844, no horizontal overflow at
either. 26 frontend and 122 backend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The isPlaying subscriber is unselected. Every store write during Vibe's
feedback/replan handshake called play() on the element that had just ended.
That replayed its final buffered milliseconds until the next source loaded.
Gate that subscriber and the seek subscriber on an actual value change, and
never resume a finished element.
Then close the gap the handshake leaves behind. The engine now drives two
<audio> elements. The next track buffers into the idle one 20s early. The
handover starts before `ended`, so the round-trip happens under the outgoing
tail. With a crossfade, that tail fades out under the new track. With crossfade
off, the new track waits in silence and starts the moment the tail ends.
Both are configurable under Settings -> Transitions and persist to
localStorage. Preload is on and crossfade is 400ms by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
next() did `queue.slice(idx + 1)`, so the current track was always
queue[0]. prev()'s `idx > 0` guard could therefore never pass after an
auto-advance — Previous did nothing, ever — and repeat: 'all' jumped to
queue[0], which is the track that just finished, looping the last track of
an album instead of restarting it.
Replaced with a currentIndex cursor; the queue is no longer trimmed behind
the playhead. The old slice did serve a purpose — bounding Vibe-prefetch
growth — so that is preserved as a MAX_HISTORY = 50 cap that drops the
oldest entries and re-bases the index, rather than dropped outright.
setQueue/playTrack/setCurrentTrack recompute the cursor, next()/prev() fall
back to findIndex if it drifts, and shuffle now picks by index so the
cursor stays valid.
Consumer audit: NowPlayingPanel and Vibe.tsx already derived position via
findIndex and needed no change. TrackRow.handlePlay did
`setQueue(queue.slice(index))`, which re-broke prev at the point of click
even with the store fixed; it now passes the intact queue.
This commit also includes a pre-existing uncommitted fix from the working
tree (not authored by Claude): the end-of-queue auto-resume loop, which
stops playback at the end of the queue instead of restarting. It is correct
and independent of the cursor bug, and is preserved verbatim here.
REVIEW-2026-07-30.md finding 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>