Server half of the offline-replay capture. New writer path
RecordOfflinePlay: writes a complete start+end play in one txn from
a caller-supplied `at` + duration_played_ms, applying the spec §6
skip rule (same AND-of-thresholds as RecordPlayEnded) and threading
`source` so #415 rotation advances for system-playlist plays just
like the live path. Generalizes RecordSyntheticCompletedPlay (which
hard-codes full completion). duration clamped to [0, track len].
New /api/events type "play_offline" → handleEventPlayOffline:
validates track + duration, reuses the existing req.At parse so the
play lands on the original timeline, not replay time. Subsonic
shim + live 3-call lifecycle untouched.
Flutter half next: EventsApi.playOffline, a play.offline
MutationQueue kind, and PlayEventsReporter capturing the completed
play + enqueuing it when the live calls have no server id / fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The events dispatcher closed every prior open row as play_skipped on
any track-id change. Server-side RecordPlaySkipped force-sets
was_skipped=true regardless of completion, so a queue played start
to finish reported tracks 1..N-1 as skips — inflating
recommendation skip-ratios (-skipRatio*SkipPenalty) and degrading
For You / Discover quality for web listeners.
Now: on track change, close the prior row as play_ended if that
track reached ~its duration (3s tolerance, matching the Flutter
PlayEventsReporter), else play_skipped at the real last position.
Race fix: the store synchronously resets position/duration to 0 on
track change, so reading lastPositionMs at change-time would see 0
and misclassify. Track per-open-row state (openReachedEnd,
openLastPositionMs, openDurationMs) updated ONLY while the open
track is current — a track change can't clobber them before the
close branch runs.
Brings web wire behavior back in line with Flutter. Test added:
auto-advance after reaching duration → play_ended, never skipped;
existing mid-track-skip and pause-at-end tests still hold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Flutter client previously reported NO plays — mobile listening
never reached play_events, so history, recommendation scoring,
ListenBrainz scrobbles, and #415 rotation all missed mobile entirely.
Operator chose to close that gap properly as part of Stage 3.
New:
- EventsApi (api/endpoints/events.dart): play_started/ended/skipped.
- PlayEventsReporter (player/play_events_reporter.dart): state
machine over (track id, playing) mirroring the web dispatcher.
Persists an opaque client_id in secure storage. Deliberate
divergence from web: a track change inside a queue is classified
ended-vs-skipped by whether the prior track reached ~its duration
(3s tolerance), instead of web's blanket "track change = skip"
which would mark every naturally-finished in-queue track a skip
and dilute recommendation skip-ratios — the exact failure mode
that motivated doing this properly. Fail-safe: no-ops when there's
no audio handler (tests / no-audio env). App-lifecycle paused/
detached closes an open row as a best-effort skip (web pagehide
parity). Wired in app.dart postFrame.
- PlaylistsApi.systemShuffle(variant): GET the rotation-aware order.
Wiring:
- audio_handler: _queueSource carried through setQueueFromTracks
(source param); preserved across internal skipToQueueItem rebuild.
- player_provider.playTracks: source param → setQueueFromTracks.
- PlaylistCard: system playlists fetch systemShuffle and play as-is
tagged with source (no client shuffle — server already ordered).
- playlist_detail_screen: header Play + per-track tap tag source for
system playlists so rotation advances from any entry point.
Known/flagged separately: the web dispatcher likely has the same
false-skip-on-advance issue; not fixed here to keep #415 scoped and
clients' wire behavior comparable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
(rotation-aware order from the server) instead of getPlaylist;
plays the returned order AS-IS — no client Fisher-Yates, since
the server already ordered it. Supersedes #413's client shuffle
for system playlists specifically; user playlists keep getPlaylist
+ stored order.
- tags the queue with the system variant. The player store carries
_queueSource; the events dispatcher includes `source` on
play_started so the server advances that playlist's rotation.
User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.
Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/playlists/system/{discover,for-you}/shuffle returns the
caller's system playlist with tracks ordered: unplayed-this-rotation
first (shuffled), then already-heard (shuffled). When the whole
snapshot has been heard, ResetRotationState fires and the full list
reshuffles fresh.
Option A (operator's choice): a separate, intentionally-uncached
endpoint. The cached GET /api/playlists/{id} detail path stays pure
for "open to view"; this varies per play. Same JSON shape as the
detail GET so Stage 3 clients reuse track parsing with no new model.
Two explicit static routes per variant mirror the refresh handlers
and avoid chi static-vs-param ambiguity under /playlists/system/.
Empty/absent snapshot → 200 with empty track list (nothing to play,
not an error). Rotation reset failure is non-fatal — still returns a
playable reshuffled list.
No client wiring yet — Stage 3 makes web + Flutter call this on the
play/tile gesture and send `source` on play_started. Handler-level
test deferred to Stage 3 (needs the full service+pool harness; the
end-to-end path is exercised there).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).
Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
from. 'for_you' / 'discover' feed rotation; NULL for library /
user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
played_track_ids uuid[], rotation_started_at, updated_at): the
per-(user,kind) set of already-heard tracks this rotation.
Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
is now a thin source="" wrapper so the frozen Subsonic shim is
untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
track to rotation state (AppendRotationPlayed keeps the array a
set via the conflict CASE).
- /api/events play_started accepts an optional "source".
No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.
Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Fable #414. forYouHeadN/forYouTailN go 12/13 → 50/50 so the
For-You snapshot is 100 tracks, matching Discover. Motivated by the
shuffle-on-play default that just shipped (#413): a 25-track shuffle
pool repeats fast; 100 makes re-plays within a day feel varied.
pickHeadAndTail already degrades gracefully when the candidate pool
is too thin for a full head/tail split (returns top-N-by-score),
mirroring how Discover returns <100 when its buckets are thin — no
new edge-case handling needed. No build-path test asserts the
For-You total; pickHeadAndTail unit tests pass their own head/tail
values so they're unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per operator decision: in the playlist detail header, system
playlists (For You / Discover) now show a Regenerate button where
user playlists keep Download. Offline-download is intentionally
dropped for system playlists — operator chose the literal swap.
- Regenerate calls PlaylistsApi.refreshSystem(variant), invalidates
playlistsListProvider (home row tile rebinds to the rotated UUID),
and pushReplacement's to /playlists/<newId> so the open detail
screen rebinds instead of 404-ing on the stale id.
- Null id (empty library) and errors surface as snackbars.
- User playlists are unchanged (Download + Play).
The home-card kebab (#416, 7a04370) stays — web has refresh in both
the detail view and the home tile, so this matches web parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Flutter half of Fable #416. Web got a generalized
system-playlist refresh kebab in d12afda; this brings Flutter to
parity instead of leaving the affordance web-only.
- PlaylistsApi.refreshSystem(variant): POST
/api/playlists/system/{for-you|discover}/refresh, maps the
underscore model variant to the hyphenated route segment,
returns the rotated playlist id.
- PlaylistCard: top-right PopupMenuButton on system playlists
with a context-labelled "Refresh For You" / "Refresh Discover"
item. Calls refreshSystem, invalidates playlistsListProvider
(which reconciles the rotated UUID + new tracks), snackbars
the result. ScaffoldMessenger captured pre-await.
- Tests: kebab present for system, absent for user playlists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).
Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
03:00 user-local snapshot is what plays now. Stops burning server
compute on every press and stops swapping the playlist out from
under the user.
- Generalize the kebab affordance to render for any system playlist
(was Discover-only). Adds "Refresh For You" as an explicit
replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
the queue when shuffle:true. PlaylistCard passes shuffle:true for
any non-null system_variant.
Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
starting index and enables AudioServiceShuffleMode.all after
setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.
User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an About card to Settings that shows the installed version
(version+build from PackageInfo), the latest known version from
clientUpdateProvider, and a "Check for updates" button that
invalidates the provider to force a fresh poll. When an update is
available, surfaces an Install CTA that reuses the same installer
flow as the top banner.
The existing banner (shouldShowUpdateBannerProvider) is unaffected
— it gates on per-version dismissal, while the About section
always reflects the current provider state regardless of dismissal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed in f6ee837 thinking it was unused, but drain() still
reads connectivityProvider.future to gate replay attempts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ref.listen(connectivityProvider, …) at start-time mounted the
StreamProvider immediately, which kicked off checkConnectivity()
with a 2s timeout. In tests that never reach the auth state
(smoke_test cold-launch path), that Timer leaked past widget tree
dispose and tripped the still-pending-timer assertion.
Drop the edge trigger — the 3s initial + 1min periodic + post-
enqueue nudge already cover the drain paths. Worst case on
reconnect is ~60s extra latency before the queue drains, which
is acceptable for an offline-resilience layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke test failed: "A Timer is still pending even after the widget
tree was disposed." Both workers fired their initial-delay Timer
via `Timer(duration, _sweep)` and stored only the periodic ticker
in the cancellable field — the one-shot Timer leaked past dispose
and tripped the test framework's invariant check.
Track both as _initialTimer + _intervalTimer; cancel both in
dispose(). Behavior is unchanged in production (ref.onDispose only
fires on process death normally); this is purely a test-harness
fix.
User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.
**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
/ lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
permanently-failing mutation eventually drops, and next sync
reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
quarantine.unflag / playlist.append / request.create /
request.cancel — each with a Ref+payload handler that re-fires
the corresponding REST call.
**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
cached_playlist_tracks write (position = max + 1) so the
playlist detail screen shows the new track instantly. Queues
appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
No drift state for the request itself (myRequestsProvider is
still REST-only) so the row won't show on /requests until replay
succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
longer restores on failure; queues request.cancel instead.
**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.
**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
drift table)
* UI "syncing N pending changes" indicator (operator preference:
silent unless we find a concrete need)
Periodic worker that walks cached_artists for missing album lists
and cached_albums for missing track lists, then fills them via
/api/artists/:id + /api/albums/:id. Newly-discovered album covers
are pre-warmed into flutter_cache_manager's disk cache too.
Solves the "tap an artist → empty album area → pop in" experience:
artistAlbumsProvider was drift-first but the cache only got
populated when the user navigated TO an artist. SyncController's
/api/library/sync delta doesn't carry per-artist album lists (those
are query-time derived). Now the filler pre-populates them in the
background so the drift hit is real on first tap.
Pacing (intentionally conservative):
* 10-second initial delay so SyncController has time to land its
first sync — the WHERE NOT EXISTS query has nothing to do until
cached_artists is populated.
* 5-minute interval thereafter. Once steady state is reached the
sweep is a cheap drift query + early exit.
* 200ms throttle between per-entity REST requests — never competes
with active playback.
* 200 entities per sweep cap so a fresh install with thousands of
artists doesn't tie up the network for one continuous run. Next
sweep picks up where this one left off (NOT EXISTS naturally
skips already-filled rows).
Wall time estimate for a 1000-artist library: ~3-4 minutes spread
over multiple sweeps. Single round-trip per artist (new
getArtistDetail API method returns artist + albums in one shot).
Activated from app.dart's postFrameCallback alongside the existing
SyncController / Prefetcher / MetadataPrefetcher / LiveEvents
hooks. Disposed via ref.onDispose when the provider scope tears
down (effectively process death in practice).
Four related fixes to the player flow that together remove the
audio↔UI lag on track change:
1. **Prefetcher pre-warms covers + palette for the next N tracks.**
The existing prefetcher pinned audio files only. When auto-advance
landed on the next track, the cover bytes were cold → mediaItem
broadcast with artUri=null → now-playing screen stalled in
_scheduleSwap awaiting precacheImage of a file that didn't exist
yet. Each upcoming queue item now also fires
AlbumCoverCache.getOrFetch (writes bytes to disk so _toMediaItem's
peekCached returns the path) and AlbumColorCache.getOrExtract
(memoizes the dominant color). Both fire-and-forget; idempotent
if already cached.
2. **AlbumColorCache.peekColor sync getter** so the now-playing
fast-path can read the memoized color without awaiting a Future.
3. **_scheduleSwap fast path** when cover bytes + palette are
already cached (the common in-queue auto-advance case): commit
_displayedMedia / _displayedDominant synchronously in setState
without awaiting. The async preload remains as the slow-path
fallback for genuine cold cache. This is what closes the gap
the user reported: "art is loading and metadata hasn't updated
but the new song is playing."
4. **setQueueFromTracks: build source before broadcasting queue /
mediaItem.** Previously we broadcast immediately for snappy UI;
if _buildAudioSource threw, the UI showed the new track while
the player held the old source. Now: build first, broadcast
only on success. Source build is sub-100ms on warm cache so the
tap response cost is imperceptible. _suppressIndexUpdates is set
around setAudioSources + broadcast so a transient currentIndex
emission can't cross-broadcast the OLD queue's entry at the NEW
index.
5. **playbackEventStream error handler skips past failing tracks.**
Previously errors only logged. The player would go silent on a
404 / decoder failure but mediaItem stayed on the failed track —
user saw "now playing X" with no audio. Now seekToNext on error;
if at queue tail, pause cleanly so PlaybackState reflects idle.
Pixel Watch 2 stopped showing controls entirely after v2026.05.13.3's
MediaSession expansion. Reverting the additive pieces:
* systemActions back to the original 5 (play / pause / skipPrev /
skipNext / seek). stop, skipToQueueItem, setShuffleMode,
setRepeatMode, setRating removed.
* controls list back to skipPrev / play|pause / skipNext (no stop).
* stop() override removed — let BaseAudioHandler default apply
(probably needs to be a no-op for the MediaSession to stay alive
through certain lifecycle events that audio_service triggers
internally; the override was actually halting the session).
* MediaItem.rating no longer set in _toMediaItem. The Android
MediaSession.setRating() path requires setRatingType(RATING_HEART)
to actually expose to controllers, and audio_service doesn't
surface that config knob — broadcasting an unanchored rating
appears to make Wear OS reject the session entirely.
Kept in place:
* skipToQueueItem override — still needed for QueueScreen's direct
handler call (not routed through MediaSession actions).
* setRating override + LikeBridge wiring — harmless if never
invoked, and lights up automatically if we figure out how to
configure the rating type later.
* AlbumCoverCache.peekCached for sync artUri seed — that part
worked, and the failure mode would be a missing cover, not a
rejected session.
Watch should come back to its previous "sometimes works" state from
v2026.05.13.2 (basic controls only). Getting past that needs proper
MediaSession config that audio_service either doesn't expose or
requires platform-channel work.
The cacheFirst fix in 5511f87 added a yield after fetchAndPopulate
so streams never hang when populate is a no-op for this filter
(the liked-tab spinner-forever bug). Test expectation updated: the
first emission after an empty drift is now the still-empty yield
("we tried, nothing to show yet"), and the simulated drift re-emit
yields the populated rows as the second emission.
Liked tab loaded into an infinite spinner when the user had likes
in one category but not all three. Root cause: the three liked-tab
providers share one _populateLikeIds function. When the populate
writes track rows, drift watch fires for cached_likes (the table
all three providers watch). The track provider's stream re-emits
with rows.isNotEmpty → yields populated. The album and artist
streams re-emit with rows.isEmpty (user has no album/artist likes),
re-enter cacheFirst's rows-empty branch, fire populate AGAIN, drift
fires again, repeat — never yielding, .isLoading stays true forever,
UI spins.
Generalises beyond the liked case: any cacheFirst with a populate
that writes to a watched table but produces no rows matching this
filter would loop. Fix tracks coldFetchAttempted per subscription
so the first fetch is the only fetch via the rows-empty branch;
subsequent empty emissions yield empty. Also yields current rows
after a successful populate so a true no-op fetchAndPopulate (server
genuinely empty, fresh-install with no library data) doesn't hang
when drift doesn't re-emit for an empty batch.
For populated cases, the order is: spinner → brief empty yield from
the post-populate yield → drift watch re-emits with rows → populated.
UI flashes empty for one frame. Acceptable trade-off for the
no-spin guarantee.
Also matches the timeout pattern: liked providers' isOnline gains
the same 3-second timeout the home/library-list providers already
had, so a stuck connectivity check can't extend the hang.
External media controllers (Android Wear, Auto, Bluetooth dashes,
lock-screen widgets) consume the audio_service MediaSession and
silently no-op on any action that isn't in the handler's
systemActions set. Several handler methods were already implemented
but never advertised, plus stop() defaulted to a no-op — which
matched user reports of "media controller on the watch sometimes
works but doesn't play nice with Minstrel."
This patch lines the advertised surface up with what the handler
actually implements + wires a native heart-rating button.
**Expanded controls + systemActions:**
- Added MediaControl.stop to the expanded controls list.
- systemActions now also enumerates stop, skipToQueueItem (override
shipped in v2026.05.13.1), setShuffleMode, setRepeatMode, and
setRating. Without these in the set, Android 13+ drops the
corresponding callbacks from external surfaces.
**stop() override:** halts _player and dismisses the foreground
notification via super.stop(). Default just flipped processingState
to idle without releasing the audio session — external surfaces
treated that as "paused forever".
**setRating wiring (native heart-button protocol):** new LikeBridge
adapter passes through configure() carrying toggleTrackLike +
isTrackLiked closures over LikesController and likedIdsProvider.
- setRating override flips the like through the bridge and re-emits
mediaItem so the watch's heart icon updates immediately.
- _toMediaItem populates MediaItem.rating on every track change so
the right filled/outlined heart shows on track-A → track-B.
- PlayerActions ref.listen on likedIdsProvider calls
refreshCurrentRating so toggling a like from TrackRow / kebab /
another device (SSE) also keeps the watch icon in sync.
**artUri seed on first broadcast:** AlbumCoverCache.peekCached
returns the file path synchronously when the cover is already on
disk. _toMediaItem uses this so warm-cache tracks broadcast with
artUri populated from the first frame — external controllers see
the cover immediately instead of waiting for the later async
_loadArtForCurrentItem path. Cold-cache tracks fall through to that
path unchanged.
Regression from v2026.05.13.2's load-then-swap rewrite. _displayedMedia
only got populated by the ref.listen callback on mediaItem changes,
but ref.listen doesn't fire on initial subscription — it only fires
on transitions after the listener is registered. So opening the full
player while a track was already playing left _displayedMedia null
and the screen rendered "Nothing playing." even though the mini bar
showed a live track.
initState now reads the current mediaItem synchronously and seeds
_displayedMedia immediately (and _displayedDominant from the color
provider's cached value when available). A post-frame
_scheduleSwap(current) runs to ensure the cover bytes are decoded
and dominant color resolved when the user opens the player to a
track whose album hasn't yet been color-extracted in this session.
Previous fixes layered AnimatedSwitcher fades on top of a race: the
audio_handler broadcasts MediaItem twice on every track change
(bare metadata first, then with artUri once AlbumCoverCache resolves)
and the image bytes themselves decode asynchronously after the
widget mounts. The fades just smeared the resulting placeholder
flash without addressing the underlying ordering.
Rewrite the decision process around "load first, then swap":
**Mini player** (rapid change is acceptable per operator preference):
- Drop AnimatedSwitcher entirely
- PlayerBar becomes stateful, holds the most-recent non-null artUri
- Builds the child MediaItem with artUri = currentArtUri ?? _lastArtUri,
so the previous cover stays visible across the null-artUri gap and
the new cover snaps in the moment its artUri arrives
**Full player** (operator wants the image fully loaded before any
visible change):
- Introduce _displayedMedia + _displayedDominant state
- ref.listen on mediaItemProvider schedules a preload for each new
track id (and for the artUri-bearing rebroadcast on the same id)
- _scheduleSwap awaits precacheImage on the file:// artUri AND
awaits albumColorProvider's future for the dominant color
- Only then setState flips _displayedMedia + _displayedDominant in
one frame — cover, title, gradient all advance atomically
- Drop the per-element AnimatedSwitcher wrappers; the backdrop
AnimatedContainer still tweens between successive dominant
colors so the gradient transition is smooth, not snap
Concurrency: rapid skips drop stale preload completions via
_pendingPreloadId. Decode/color failures fall through to the
previous dominant + the ServerImage/error-builder fallbacks.
Multi-artist surfaces (home Rediscover, Library Artists tab, Liked
Artists carousel) were all rendering the music-notes placeholder
instead of real artist covers. Root cause:
CachedArtist.toRef() returned an ArtistRef with empty coverUrl —
the cache doesn't store the representative album id the server
derives at query time, and the adapter never reconstructed it.
The artist detail screen worked coincidentally because it derives
its header cover from `artist.albums[0].id` directly rather than
the ArtistRef's coverUrl field.
Fix:
* CachedArtistAdapter.toRef() now accepts an optional coverAlbumId
and reconstructs `/api/albums/<id>/cover` when given. Matches the
pattern AlbumRef uses (deterministic URL from entity id).
* artistTileProvider, libraryArtistsProvider, and artistProvider
(single-artist) each LEFT JOIN cached_albums ordered by sort_title.
First row per artist carries the alphabetically-first album id;
toRef projects that into the cover URL.
* Multi-artist queries dedup in toResult since the join multiplies
rows by album count.
Artists with no albums yet in drift come through with empty
coverUrl — UI falls back to the music-notes placeholder, same
behavior as before for that legitimately-coverless state.
Four-part change to push more surfaces onto the drift cache and
eliminate cold-tab-visit latency on the Library screen.
* **Library Artists tab** — _libraryArtistsProvider migrates from
REST-paginated AsyncNotifier with infinite-scroll loadMore to a
drift-first StreamProvider over cached_artists ordered by
sortName. Sync already populates the full set; cacheFirst's
fetchAndPopulate covers the fresh-install + sync-not-yet-done
cold case via /api/artists?limit=1000. SWR refresh on every
visit. GridView.builder lazily realizes only visible cells so
loading the full list up front is fine for typical libraries.
loadMore + NotificationListener gone.
* **Library Albums tab** — same migration, drift-first over
cached_albums joined with cached_artists for the artistName
field.
* **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus
single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot)
for the home Playlists row's "building / pending / failed"
placeholder logic. Drift-first means the row paints with the
prior status instantly instead of flickering through
SystemPlaylistsStatus.empty() while the REST call resolves.
* **Library screen tab pre-warm** — ref.listen on all 5 tab
providers in _LibraryScreenState.build subscribes them upfront
so swiping between tabs feels instant rather than each tab
paying its own cold-cache cost on first visit. cacheFirst
handles dedupe of concurrent fetchAndPopulate triggers.
Test mock updated for the StreamProvider type change on
systemPlaylistsStatusProvider.
ArtistCard hardcoded its avatar at Container(width: 124, height:
124) inside a ClipOval. In the Library Artists 3-column grid the
cell is narrower than the card's nominal 140dp width — on a typical
phone the cell is ~109dp, the padded inner content area ~93dp. The
parent constrained the Container's width to ~93dp but the explicit
height stayed 124dp, so ClipOval clipped a 93×124 rectangle and
the avatar rendered as a vertical ellipse.
Fix mirrors AlbumCard's pattern: ArtistCard takes an optional
`width` parameter (default 140 for horizontal carousels) and
derives coverSize = width - 16, so the Container is always square.
ArtistsTab now uses LayoutBuilder to compute cell width and passes
it through, same as AlbumsTab. Avatar stays a true circle at any
cell width.
mainAxisExtent on the grid replaces the previous fixed
childAspectRatio so cell height tracks cellW + name line, with
slack matching AlbumsTab's overflow guard.
Playlist collages aren't generated until the build job runs over a
playlist with tracks — system playlists (For-You / Discover / Songs-
like) and any newly-created playlist hit a brief window where
/api/playlists/:id/cover returns 404. ServerImage's errorWidget
already renders the visual fallback (queue_music icon over slate);
this fix just keeps cached_network_image from spamming the dev
console with HttpExceptionWithStatus stack traces.
errorListener filters 404 specifically — auth (401/403) and any
5xx still log so real connectivity / permission issues stay visible.
User-visible behavior unchanged; this is a dev-mode log hygiene fix.
Discover playlists could surface the same track twice with the
duplicates landing back-to-back — a "first song plays, then plays
again, skip works" symptom user reported on v2026.05.13.0. Root
cause: interleaveBuckets rotates one track per pass per bucket but
never tracks which IDs it has already emitted, so a track that's
both a dormant-artist pick AND a random-unheard pick comes out
once from each bucket.
On a single-user server the crossUser bucket is empty, so the
redistribute step rolls its slots into dormant + random. Their
output then interleaves d0, r0, d1, r1, … — and when d0 == r0
(common: a dormant-artist track is also valid for random-unheard)
the result is [X, X, …] with adjacent duplicates.
Fix: track seen track IDs across all buckets while interleaving;
skip already-taken IDs and advance to the next index in that
bucket. Dedup priority is bucket order, so a track in both
dormant and random comes from dormant.
Regression test covers the single-user case directly. The existing
round-robin test still passes — no shared IDs in that fixture.
Note: stale duplicates already written to drift / served as cached
playlists will clear naturally on the next playlist rebuild (the
03:00-local refresh, or any manual /api/me/playlists/refresh).
Two related "snap in" effects on the now-playing screen:
1. **Album art snapped in after the fade.** AnimatedSwitcher cross-
fades the new _AlbumArt over 300ms, but FileImage's bytes weren't
decoded yet — the widget was visually empty during the fade and
the cover landed abruptly after. precacheImage on the new file://
artUri pre-decodes the bytes so by the time AnimatedSwitcher
mounts the new tile, the cover paints synchronously inside it.
The cross-fade now carries real content end-to-end.
2. **Backdrop color snapped in.** albumColorProvider.family is
loading for the new id during the track-change moment, so
dominant fell back to fs.obsidian; AnimatedContainer tweened to
obsidian and then snapped to the resolved color a beat later.
_NowPlayingScreenState now holds _lastDominant across builds:
while extraction for the new id is loading, the gradient stays
on the previous album's color, then animates straight to the
new one once it resolves. No intermediate obsidian stop.
Net effect: track changes feel like a single smooth transition
instead of fade-out → blank → snap.
Audio handler broadcasts MediaItem twice on every track change:
once with artUri=null (the new track's bare metadata), then again
with artUri pointing at the AlbumCoverCache file once the cover
lands on disk. The mini player's cover element was rebuilding in
place: previous track's image → slate placeholder → new track's
image. That flash is the flicker reported on the v2026.05.13.0 build.
AnimatedSwitcher around the cover (180ms crossfade) keyed by the
artUri value makes the swap a smooth crossfade instead of a visible
snap. The Hero parent stays — its tag is stable across track changes
so the mini→full bar expansion animation keeps working unchanged.
Two bugs in the audio handler caused the playback issues seen on the
v2026.05.13.0 build:
1. **Queue button on the now-playing screen did nothing.** MinstrelAudio
Handler never overrode skipToQueueItem, so taps in QueueScreen fell
through to BaseAudioHandler's empty default. The queue UI updated
nothing because the handler did nothing. Now overridden: rebuilds
the source list via setQueueFromTracks(_lastTracks, initialIndex)
so the targeted item plays cleanly even when its source hadn't been
built yet by the background fill.
2. **Tapping a song in a playlist let the previous track bleed through
until the new source finished building.** setQueueFromTracks awaits
_buildAudioSource before swapping the player's source list, and
that wait can be a few hundred ms on a cache miss. During the wait
the old source kept playing while the mini player UI had already
flipped to the new title/artist. Now pause()ing the player at the
start of setQueueFromTracks silences the old source the moment the
user taps.
Also stashes the most recent TrackRef list as _lastTracks so
skipToQueueItem can reconstruct sources without having to peek into
just_audio's internal source list.
Final slice of the per-item rendering pass. Wraps every tile widget
in an AnimatedSwitcher between the skeleton placeholder and the real
card. 220ms cross-fade with easeOut: tiles "settle into place"
rather than hard-cutting from shimmer to content. Since each tile
fades independently as its data lands — and the HydrationQueue's
concurrency cap drains in a natural cascade — the overall feel is
the staged "page builds piece by piece" effect we wanted, with no
per-tile position math required.
Bumps CachedNetworkImage fadeInDuration from zero to 120ms (server_
image.dart, discover_screen.dart). Imperceptible on cache hits since
the image decodes synchronously; on cache misses the bytes fade in
smoothly instead of popping. Slice 1's "zero fade" call was right
about the 500ms default being a regression, but 120ms threads the
needle.
Playlist detail wraps its body in the same AnimatedSwitcher so the
cold-load skeleton page cross-fades into the real track list.
Tiles affected: home _AlbumTile / _ArtistTile / _TrackTile + liked
_LikedAlbumTile / _LikedArtistTile / _LikedTrackRow + playlist
_SkeletonBody / _Body. All keyed via ValueKey so AnimatedSwitcher
detects the transition.
End of the per-item pass. Net behavior: cold visits paint shaped
pages instantly with skeletons, content cascades in as hydration
lands; warm visits paint fully from drift in the first frame.
The three liked-tab providers now yield ordered lists of entity IDs
(read from cached_likes ORDER BY likedAt DESC). The UI renders
per-tile widgets that hydrate each entity individually via
albumTileProvider / artistTileProvider / trackTileProvider.
fetchAndPopulate dropped from the per-provider bulk endpoints to a
single shared call against /api/likes/ids — much cheaper, and the
tile providers handle entity hydration themselves. The bulk
/api/likes/{tracks,albums,artists} endpoints are no longer in the
Flutter cold path (server keeps serving them for web compat).
Cross-device SSE invalidate paths preserved so cross-device likes
still feel instant. Local LikesController mutations propagate via
cached_likes optimistic writes — same drift watch() route as before.
Tap-to-play on a track row uses currently-hydrated TrackRefs as the
play queue; still-loading tracks are skipped and join on next
rebuild as hydrations land.
Cold-visit playlist detail used to render the header from the seed
and then a single CircularProgressIndicator while the bulk fetch
ran. Now it renders the header + seed.trackCount worth of skeleton
rows (capped at 8 when no seed is present). The real list swaps in
without a layout jump.
Slice D was originally scoped for per-track hydration through a new
discovery endpoint, but the unavailable-entries data model (playlist
rows that lost their underlying track to deletion / quarantine) make
that disproportionately expensive — would require a schema change to
support null trackIds on cached_playlist_tracks, a server endpoint,
and a screen rewrite. The skeleton-row approach captures ~80% of the
perceptual win at a fraction of the cost. True per-track hydration
remains a future opportunity if cold visits to very large playlists
still feel sluggish after Slice F polish lands.
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.
Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.
The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
smaller than /api/home), then sections appear shaped with
skeleton tiles; each tile materializes as the hydration queue
drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
real cards next to skeleton cards. Layout doesn't shift because
skeletons match real-card dimensions exactly.
CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.
Old /api/home endpoint untouched, so the web client keeps working
unchanged.
Sibling to /api/home that returns the same five sections (recently
added, rediscover albums, rediscover artists, most played, last
played) but as flat slices of entity ID strings instead of
denormalized objects. The Flutter client uses this to drive its
per-item rendering pass — small discovery response then per-tile
hydration via the existing /api/albums/:id, /api/artists/:id,
/api/tracks/:id endpoints.
Reuses recommendation.HomeData so the DB cost is identical to
/api/home. JSON payload shrinks roughly an order of magnitude on
populated libraries (no embedded title / artist / cover URL fields).
Old /api/home stays untouched so the web client and older Flutter
builds keep working — no min-client-version bump needed until both
clients have migrated.
Slice A landed with three transitive imports that the analyzer
correctly flagged as unused. AppDb / CachedAlbums table refs
propagate through audio_cache_manager.dart's `show appDbProvider`
re-export chain so the explicit db.dart import in the consumers
is redundant.
Plumbing for the per-item rendering pass — no UI changes yet, just
the layers the home/playlist/liked migrations will sit on.
* CachedHomeIndex drift table (schema 5→6) — section/position →
entity-id rows, populated by the upcoming /api/home/index endpoint.
* HydrationQueue (concurrency=4, in-flight dedup) — bounded request
pump that takes (entityType, entityId) and persists the result to
the right cached_<entity> table. Albums + artists wired today;
tracks deferred until /api/tracks/:id exists.
* Per-entity tile providers (albumTileProvider, artistTileProvider,
trackTileProvider as StreamProvider.family) — watch drift, enqueue
hydration on miss, yield AsyncValue<EntityRef?>.
* Skeleton widgets (album/artist/track) matched to the real card
dimensions with a 1.2s shimmer sweep using FabledSword tokens. No
shimmer-package dep — single AnimationController per surface.
See docs/superpowers/specs/2026-05-13-per-item-rendering-design.md
for the full architecture rationale.
Final slice of the smooth-loading pass. Adds CachedQuarantineMine
(schema 5, columnar so flag/unflag can do row-level mutation) and
rewires MyQuarantineController to read from drift via watch() + SWR
refresh; flag/unflag write drift first and roll back on REST failure.
Public API (.flag / .unflag / .isHidden) unchanged so existing call
sites (library_screen Hidden tab, TrackActionsSheet) keep working.
Tests updated to match: bypassed-build-via-_StubController approach
no longer makes sense now that state lives in drift, so the suite is
rewritten against NativeDatabase.memory() with the same libsqlite3
skip the sync_controller suite uses on the CI runner.
The Hidden tab now paints from disk on cold open, the list is
queryable offline, and a flag from another device that arrived in
this user's quarantine via SSE-triggered invalidate lands the same
way as a local flag.
Slice 4 of the smooth-loading pass. Adds CachedHistorySnapshot
(schema 4) and rewires _historyProvider through cacheFirst, mirroring
the homeProvider pattern: yield the last cached page immediately on
subscribe so the tab paints from disk on cold open, then SWR-refresh
in the background to surface fresh plays.
Also enables basic offline scrollback — the most recent History page
survives both app restart and connectivity loss.
JSON blob storage (vs columnar) because the page is small, always
read whole, and HistoryPage.fromJson already accepts the wire shape,
so server-side field additions don't force a migration.
History delta sync via library_changes is out of scope here; the next
visit's SWR pull is the source of freshness for now.
Slice 3 of the smooth-loading pass. The three _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider entries on the Library
screen migrate from FutureProvider+REST to StreamProvider+cacheFirst.
Reads now flow from cached_likes joined against the metadata tables
SyncController already keeps fresh; LikesController's optimistic drift
write makes toggling a like re-emit these streams instantly without a
REST round-trip. Cold-cache fallback hits /api/likes/* when drift is
empty (fresh install pre-first-sync). SWR refresh on each visit catches
likes from other devices that haven't propagated via library_changes
yet.
The original FutureProvider versions fetched the first 50 rows. Drift
returns everything cached_likes knows about — for typical libraries
that's the full liked list. Pagination can come back when liked lists
are big enough to matter.
Like/unlike SSE invalidation paths preserved so cross-device updates
still feel real-time, even when the sender's library_changes hasn't
landed here yet.
Slice 2 of the cover-caching pass. SyncController now downloads cover
bytes for newly-upserted albums + playlists into the shared
flutter_cache_manager disk cache after each sync transaction commits.
A cold-start scroll through the home grid paints from disk on the very
first frame instead of firing one HTTP per visible tile.
Best-effort: fire-and-forget after commit, concurrency 3, per-URL
failures swallowed (404 for collages that haven't built yet, 401
during token-refresh races). Artist covers skipped — ArtistRef.coverUrl
is server-derived from "most-recent album" and not reconstructible
client-side; album pre-warm already covers the artist's primary visual.
Auth header reuses sessionTokenProvider for parity with ServerImage.
Slice 1 of the cover-caching pass. The previous Image.network /
NetworkImage path only cached covers in memory, so a scroll-off + scroll-
back or an app restart re-downloaded every tile from the server. Swap
to cached_network_image so bytes land on disk (path_provider temp dir,
URL-keyed) and survive both.
Sites migrated:
- ServerImage (all /api/*/cover usage — home grid, library, playlist,
artist/album detail headers)
- DiscoverScreen Lidarr suggestion thumbnails
- PlayerBar mini cover (HTTPS branch; file:// branch unchanged since
AlbumCoverCache files are already on disk)
Auth header forwarding preserved via httpHeaders. Fade-in disabled so
populated grids paint instantly on cache hit.
Slice 2 (pre-warm during sync) builds on this same cache manager.
Drops the staleness gate from 1h to 1m and adds a Timer.periodic that
fires recheckIfStale every minute while the app is foregrounded. Net
effect: ~1 check per minute of active use, ~60 KB/hr data — trivially
affordable for the value of faster recovery when min_client_version
bumps server-side.
Timer is paired with the lifecycle observer: started in initState +
on resume, stopped in dispose + on pause/inactive/hidden/detached so
backgrounded apps don't burn battery on probes the user can't see.
Staleness gate still wraps the call so concurrent triggers (timer +
resume firing close together) dedupe to one network call. Manual
"Check now" still bypasses the gate via recheck().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-start spinner was up to ~38s on slow / remote connections
because VersionGate blocked the entire ShellRoute on /healthz, with
the default dio's 8s connect + 30s receive timeouts. The /healthz
server handler itself is fine (microsecond JSON encode); the blocker
was client-side. Three issues fixed in one pass:
1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget
that always renders its child and just activates the version
check controller on mount. The "you're too old" experience moves
from a full-screen hard-block (_TooOldScreen, deleted) to a soft
banner above the AppBar that lets the user keep playing cached
content while they update.
2. 1h-throttled background check. New VersionCheckController
(AsyncNotifier) hydrates from a secure-storage cache on boot,
returning the cached result instantly. If the cache is missing
or >1h old, fires a background recheck. AppLifecycleState.resumed
triggers recheckIfStale so foregrounding after >1h re-checks
without per-frame hammering. "Check now" button on the banner
bypasses the staleness gate so dev iteration (push new APK, want
to see banner clear) doesn't wait an hour.
3. Bounded health-check dio. The /healthz request uses a dedicated
dio with connectTimeout: 3s + receiveTimeout: 2s rather than the
default 8s / 30s. Health probes should fail fast — if the server
can't ack in 5s, the user has bigger problems than a stale
min_client_version and the cached value remains in effect.
Cache keys live alongside the existing tz cadence cache in
flutter_secure_storage (kResult + kAtMs). On any network error or
parse failure, _runCheck soft-fails without bumping the timestamp,
so the next staleness check will retry.
VersionTooOldBanner renders in _ShellWithPlayerBar's Column above
the existing UpdateBanner — the two coexist when both apply
(server rejects you AND an APK is queued).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last deferred follow-up from #357. The library_changes
table is the append-only change log that drives /api/library/sync's
delta semantics — every mutation (scanner upsert, like, playlist
edit, track delete) writes one row. Without a retention policy the
table grows unbounded; the original migration (0025) called out the
follow-up explicitly.
New goroutine: sync.Compactor runs daily, deletes rows where
changed_at < now - 30 days. Logs a row count when non-zero so
operators can see compaction activity in the journal. First tick
fires on startup so a process that hasn't been compacted in a
while catches up immediately.
30-day retention matches the offline-mode spec
(docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md).
Clients with a cursor older than that hit the existing 410 fallback
path and resync from scratch.
Imported as syncpkg in main.go to follow the existing convention
(see internal/library/scanner.go).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Home screen is the first surface on app open; without a local cache
the cold-start blocks on /api/home, which dominates felt latency on
slow or remote connections. This commit caches the last successful
HomeData as a single-row JSON blob in drift, so subsequent app opens
yield content immediately and revalidate in the background.
Schema:
- New CachedHomeSnapshot table (single row: id=1, json TEXT,
updated_at). schemaVersion bumped 2 → 3 with a forward migration
that calls m.createTable(cachedHomeSnapshot). Codegen regenerated
via build_runner; the *.g.dart files are gitignored and rebuilt
by the CI Codegen step.
Provider rewrite:
- homeProvider: FutureProvider<HomeData> → StreamProvider<HomeData>
using the existing cacheFirst<CachedHomeSnapshotData, HomeData>
pattern (alwaysRefresh: true for SWR). On cold cache the first
/api/home fetch populates the row. On warm cache the cached
HomeData is yielded immediately and a background REST fetch
overwrites the row, which drift's watch() picks up.
- Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so
HomeData survives the JSON round-trip into and out of drift.
Field names match the server's /api/home wire shape exactly so
HomeData.fromJson handles both fresh server responses and cached
drift rows.
Callers untouched: home_screen.dart's ref.watch + ref.refresh +
metadata_prefetcher's ref.listen all keep working with the
StreamProvider shape (AsyncValue<HomeData> stays the surface type).
Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart
switched from `(ref) async => _emptyHome` (FutureProvider form) to
`(ref) => Stream.value(_emptyHome)` (StreamProvider form).
For #357. Completes the user-visible deferred follow-up. Remaining
deferred items: library_changes server-side retention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#401 introduced player.current?.id reads into TrackRow.svelte and
PlaylistTrackRow.svelte, breaking 26 test cases across 6 files whose
existing vi.mock('$lib/player/store.svelte', ...) blocks only stubbed
the functions used by the original components.
Added player: { current: undefined } to each affected mock — keeps
the existing function spies and lets the new isCurrent derivation
read a defined (false-y) player.current without blowing up.
Only updated the 6 files that failed; 16 other player-store mocks
exist across the suite but their tests don't render Track/Playlist
rows so the derivation never fires. Future tests that render those
rows will need the same stub (visible at the failure point with a
clear error message).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#392's dispatcher only invalidates publicly-importable providers
(myQuarantine + home). Screen-scoped providers (file-private in their
feature folders) get their own ref.listen(liveEventsProvider, ...) so
they go live without needing back-edge dependencies from /shared.
Five screens wired:
- library_screen.dart _LikedTab — invalidates _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider on any of the six
track/album/artist like/unlike kinds.
- playlist_detail_screen.dart — invalidates playlistDetailProvider(id)
on playlist.updated / playlist.tracks_changed matching the visible
playlist_id. On playlist.deleted matching the visible id, pops back
so the user isn't left staring at a gone playlist.
- admin_requests_screen.dart — invalidates adminRequestsProvider on
request.status_changed (covers user create/cancel + admin
approve/reject + reconciler complete).
- admin_quarantine_screen.dart — invalidates adminQuarantineProvider
on any quarantine.* event (flag from a user / admin resolve / file
delete / lidarr delete).
- requests_screen.dart (own requests) — invalidates myRequestsProvider
on request.status_changed. Server-side events are user-scoped via
publishRequestStatusChanged's row.UserID, so admin actions on
someone else's request route to the right stream.
History tab is NOT wired (no server-side play.scrobbled event yet —
documented in #402 body as deferred until that event ships).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#392 shipped track.liked / track.unliked but skipped the album +
artist symmetric pairs. Closes that gap so the Flutter Liked tab's
albums and artists sub-lists can listen for changes the same way
the tracks sub-list will (#402 wire-up lands next commit).
publishLikeEvent already handles the entity_type dispatch; the four
handler call sites just need the new lines.
For #402 follow-up to #392.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-platform consistency: when a track is currently playing, its row
on the album / liked / history / search / playlist detail surfaces
gets the same accent-border + bg-lift treatment that QueueTrackRow
applies on the queue panel. Closes the inconsistency caught during
the #375 DRY audit (the queue row had a "you are here" indicator;
other track-row surfaces did not).
Web — TrackRow.svelte + PlaylistTrackRow.svelte:
isCurrent = player.current?.id === track.id
→ border-l-2 border-l-accent bg-surface-hover when true.
PlaylistTrackRow additionally gates on !isUnavailable so deleted
rows never match a phantom playing id.
Flutter — TrackRow + _PlaylistTrackRow:
TrackRow becomes a ConsumerWidget, watches mediaItemProvider, and
wraps its InkWell in a Container whose BoxDecoration carries the
fs.iron background + 2px fs.accent left border when current. Title
text also shifts to fs.accent and FontWeight.w500. Same pattern for
_PlaylistTrackRow.
No "Now playing" pill on these surfaces — the player bar already
names the track, so the accent band alone reads as enough cue.
For #401 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues caught by flutter analyze --fatal-infos:
- dart:ui import in album_color_extractor.dart was redundant because
flutter/painting re-exports the Color it provides. Dropped.
- valueOrNull isn't on AsyncValue in this Riverpod version (the
AsyncValue<Color?> nesting may also have confused the resolver).
Switched to asData?.value which always returns the wrapped value
on AsyncData and null on Loading/Error.
(palette_generator's "discontinued" warning is non-fatal informational
in pub; CI didn't fail on it. The package still works; alternative
swaps deferred until it actually breaks.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the four locked items (1, 2, 4); item 6 (swipe-tabs) stays
deferred until server-side lyrics ingestion exists.
1. Dominant-color gradient backdrop. New album_color_extractor.dart
wraps the existing AlbumCoverCache: extracts the dominant color
via PaletteGenerator over the local file, caches in-memory keyed
by album_id. Top 55% of the screen carries the color (0.55 alpha
→ fs.obsidian) so controls below stay legible. AnimatedContainer
tweens the gradient across track changes.
2. Hero transition for cover art (mini bar → full screen). Stable
kPlayerCoverHeroTag (not media.id keyed) so the transition works
regardless of what's playing and isn't racy if media swaps mid-tap.
flightShuttleBuilder renders the destination's Hero widget for the
whole flight, which reads as a clean grow rather than a swap.
4. Crossfade on track change. AnimatedSwitcher around the album art,
title, and artist+album text block, all keyed by media.id so the
switcher fades between old and new on each track-change rebuild.
Pairs with the AnimatedContainer gradient so the whole "what's
playing" zone changes in lockstep.
palette_generator: ^0.3.3 added.
For #396 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaylistTrack.streamUrl is String? (nullable when track is unavailable
post-delete); TrackRef.streamUrl is required String. flutter analyze
caught the mismatch. Coalesce to empty string for unavailable rows —
they're already filtered out by the trackId != null check earlier in
the loop, so this branch is effectively unreachable but keeps the
type-checker happy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AlbumCard / ArtistCard / PlaylistCard gain a 44dp circular play
button overlaid bottom-right of the cover art. Mirrors the
hover-revealed .play-overlay on the web cards; always visible
because hover is not a real interaction on touch.
Per-card semantics match the web:
- AlbumCard: fetches /api/albums/{id}, starts playback from track 0.
- ArtistCard: fetches /api/artists/{id}/tracks, Fisher-Yates shuffles,
plays from index 0 (matches web's playQueue(shuffle(tracks), 0)).
- PlaylistCard: fetches /api/playlists/{id}, materializes available
rows into TrackRef, plays from index 0. Disabled state when
trackCount == 0 — semi-transparent button, taps ignored.
Shared PlayCircleButton widget manages loading state (spinner during
fetch) so each card's onPressed can stay async without re-entrancy
guards. Cards become ConsumerWidget so they can reach the
playerActionsProvider + relevant API providers; constructor surface
unchanged so existing call sites (artist_detail, library_screen,
home_screen) keep working.
CompactTrackCard unchanged — its tap already plays-from-here.
For #393 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter client posts FlutterTimezone.getLocalTimezone() to
PUT /api/me/timezone on every setSession (login / register success)
and on every AuthController.build (app cold-start with valid
session), when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in flutter_secure_storage so it survives app
restarts.
Failures swallowed: the server's UTC default + last-known value
keep the scheduler functioning until the next attempt.
Completes the client side of #392 Half B (per-user timezone
scheduling).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web client posts Intl.DateTimeFormat().resolvedOptions().timeZone to
PUT /api/me/timezone after every successful login, register, and
bootstrap when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in localStorage keeps the server stateless on the
"is this stale?" check.
Failures swallowed: the server's UTC default + last-known value keep
the scheduler functioning until the next successful attempt. SSR-
safe via the typeof window guard.
For #392 Half B. Companion Flutter change in next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:
CRON_TZ=America/New_York 0 3 * * *
Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.
Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.
Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the server side of #392 Half B.
PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.
POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.
system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.
Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.
Stop drains and shuts down the gocron loop.
Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.
Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema + endpoint scaffolding for #392 Half B (per-user timezone
scheduling). Adds two columns to the users table:
- timezone text NOT NULL DEFAULT 'UTC' (IANA name)
- timezone_updated_at timestamptz (nullable; populated on each PUT)
PUT /api/me/timezone validates the IANA value via time.LoadLocation
and writes the row. No scheduler integration yet — the scheduler
struct lands in the next commit and the handler-side Refresh call
in the commit after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five diversity mechanics — applied to both For-You and Songs-Like-X.
1. For-You seed rotates daily across the user's top-5 most-played
tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
len(seeds) so today's mix uses an entirely different similarity
pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
by userIDHash(user, day) rather than the no-op, so near-tied
candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
playlist comes from the tail now (daily-deterministic via
tieBreakHash), giving the user substantially different content
while a 12-track anchor of strong similarity matches keeps the
mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
played artists. pickSeedArtistsForDay applies a userIDHash-seeded
Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
parameter so the RNG can be seeded per-user; existing call sites
updated; noopRNG removed.
Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.
PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.
For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For-You + Songs-Like-X seed selection moves to Go-side daily rotation
(next commit). The SQL change just widens the candidate pool: top-5
played tracks instead of single top played track; top-5 artists
instead of top-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 4 — completes the #392 hybrid live-refresh loop.
live_events_provider.dart subscribes to /api/events/stream via dio's
streaming response mode, parses SSE frames (kind + JSON data + UserID
scope), and exposes them as a Riverpod StreamProvider. Heartbeat
comments are silently dropped; malformed JSON frames are skipped. The
provider auto-rebuilds when auth state changes (token rotation,
sign-out → sign-in), so reconnect is implicit.
live_events_dispatcher.dart listens to the stream and invalidates the
small set of publicly-importable providers we know about:
- myQuarantineProvider + homeProvider on any quarantine.* event
- homeProvider on any playlist.* event (Home renders the Playlists row)
Screen-private providers (library_screen.dart's _liked* /
_libraryAlbums / _history, admin screens, etc.) opt in to live-refresh
by themselves listening to liveEventsProvider in follow-up commits;
the dispatcher stays small and avoids back-edge dependencies on every
feature folder.
The dispatcher also installs an AppLifecycleState observer for
resume-time defensive invalidation. SSE will catch up on its own when
the app returns from background, but the invalidate flushes any stale
data immediately so the first frame back is fresh.
app.dart wires the dispatcher into the post-first-frame callback
alongside the other startup activations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hand-unrolled byte-pair version in reconciler.go tripped gofmt -s
and goimports. Replaced with the same loop-based formatter that
internal/library/eventbus.go uses — same behaviour, fewer lines, lint
clean. Two copies of the helper still exist (one per package) to keep
the no-back-edge property for both internal/library and
internal/lidarrrequests, but they're now identical and the duplication
is tiny.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3c — completes the server-side producer set.
Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.
Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).
This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3b — extends event publishing to background workers.
When the reconciler matches an approved Lidarr request against the
library and flips it to 'completed', it now publishes a
request.status_changed event scoped to the original requester so their
/requests page invalidates without polling. Three call sites — one per
kind (artist / album / track) — capture the completed row instead of
discarding it so the publish has the user_id.
Bus plumbing: the reconciler runs in cmd/minstrel/main.go which
constructs services before server.New is called. Moved bus
construction into main; the Server struct gained a Bus field that
Router() reads, falling back to a fresh local instance when nil
(test contexts). Result: one bus per process shared by every
publisher and the SSE subscriber endpoint.
reconciler.go inlines a uuid->string helper rather than reaching into
internal/api for one — avoids a back-edge dependency.
Test compat: 9 NewReconciler call sites in reconciler_integration_test.go
get `nil` for the new bus param. The reconciler's publishCompleted helper
is a no-op when the bus is nil so tests that don't care about events
keep passing.
Scanner producer (scan.progress) deferred to slice 3c — needs more
thought about which lifecycle transitions warrant events vs. flood the
stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3a — extends the producer set onto the SSE bus.
quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
clients invalidate their Hidden tab and admins' clients invalidate
their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
actions): broadcast because a single admin action can affect every
user who'd flagged that track.
playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
Reorder. Owner-scoped. Single kind for all three mutations because
the client invalidation logic is identical (refetch the detail).
Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 2 of #392 — wires the first producers onto the bus that slice 1
built. After this commit, an SSE subscriber sees real events fire:
- track.liked / track.unliked when the user toggles the heart on a track
(handleLikeTrack / handleUnlikeTrack). Album + artist like events
intentionally deferred — they're symmetric trivial follow-ups but the
operator's primary like surface is tracks.
- request.status_changed when a Lidarr request is created, cancelled,
approved, or rejected. Auto-approve will fire twice (pending then
approved) in rapid succession, which is semantically correct; client
invalidation handles that fine.
Events are user-scoped via row.UserID so admin approve/reject route to
the requester, not the admin acting. Helpers live in events_publish.go
so the wire shape (kind names, payload keys) stays in one place — future
producers in slice 3 reuse the same pattern.
events_publish.go is no-op when h.eventbus is nil so tests that
construct handlers without a bus continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go vet caught a missed test caller of api.Mount after slice 1's signature
change added the *eventbus.Bus parameter. Pass eventbus.New() in the test
— the TestRoutesRegisteredInMount test only walks the route table for
existence, never publishes or subscribes, so a throwaway bus is fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1 of the #392 hybrid live-refresh work. Ships the in-process pub/sub
bus and the SSE subscriber endpoint; no producers wired yet, so the stream
emits only heartbeats today. Verifiable in isolation by curl-ing the
endpoint with a valid Bearer token — the connection opens, ": heartbeat"
lines arrive every 15s, the connection closes cleanly on client disconnect.
eventbus.Bus is a small fan-out broadcaster: subscribers register through
Subscribe (returns a receive channel + an unsubscribe closure), writers
call Publish, and the bus drops events for any subscriber whose buffer is
full rather than blocking the writer. No persistence — clients are
expected to resync via normal /api/* fetches on (re)connect.
The SSE handler emits an initial ": connected" comment so the client sees
the connection open immediately, then forwards events whose UserID matches
the authenticated user (or is empty for broadcast). Heartbeat comments
keep proxy connections alive. Context cancellation cleanly tears down the
subscription on client disconnect.
Producers (likes, request status, quarantine, scanner, playlist mutations)
land in subsequent slices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small parity gaps in _HiddenTab vs the web /library/hidden page:
- No unhide affordance — once a track was flagged via the kebab, the only
way to reverse it was to find the track in another surface and toggle
via the kebab again. Added an Icons.restore IconButton on each tile that
calls myQuarantineProvider.notifier.unflag(trackId) (the optimistic
remove-with-rollback already lived on the notifier).
- No album cover art — added a 56px ServerImage thumb matching the web
page's 14×14 thumb, with the same fs.slate fallback compact_track_card
uses for missing covers.
- No relative timestamp — appended _relativeTime(row.createdAt) next to
the reason pill so the user can tell "I hid this 3d ago" at a glance.
Also collapsed the duplicate provider: _HiddenTab was watching a local
FutureProvider that didn't see flag/unflag mutations, while the kebab's
HideTrackSheet flow goes through the canonical myQuarantineProvider
(AsyncNotifier). Switched _HiddenTab to watch myQuarantineProvider so
flag-from-anywhere and unhide-from-the-tab stay in sync. The local
_quarantineProvider was deleted; one source of truth now.
Caught during the #375 DRY audit cross-check against the #356 inventory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DRY pass audit (#375) found two inline mock patterns repeated across the
vitest suite: a default empty-playlists mock for $lib/api/playlists (4 exact
copies in menu/card tests) and an api-client spy mock for $lib/api/client
(9 callers split between get-only and full-RESTy shapes — unified into one
helper that always returns all four verb spies).
Mirrors the existing test-utils/mocks/likes.ts and test-utils/mocks/quarantine.ts
convention. Tests with intentionally divergent shapes (AddToPlaylistMenu's
richer createPlaylist payload, PlaylistCard's getPlaylist, route-specific
mocks, events.svelte's specific resolved value) stay inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cross-user bucket query combined SELECT DISTINCT with ORDER BY md5(...),
which Postgres rejects at plan time (SQLSTATE 42P10). buildDiscoverCandidates
returned that error on first bucket failure, so the whole Discover playlist
was skipped every nightly run — even though the random bucket pool was
healthy. Switched to GROUP BY so the md5 ordering expression no longer needs
to appear in the select list, and hardened the function so future single-
bucket failures degrade gracefully via slot redistribution instead of taking
out the whole playlist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated issues, batched.
Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.
Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
}
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.
Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.
Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
Same root cause as the /queue duplicate-page-key crash: /now-playing
is a top-level route (lives outside the ShellRoute), but
/artists/:id and /albums/:id are shell-children. Pushing a shell-
child from a top-level route makes go_router attempt to mount a
second ShellRoute on top of the active one, leaving navigation in a
broken state. The mini player works because it's already inside the
shell.
Add an optional onBeforeNavigate callback to TrackActionsSheet
(forwarded through TrackActionsButton). When set, fires after
sheet.pop() and before context.push() of the destination route.
Wire the full player's TrackActionsButton with onBeforeNavigate:
() => Navigator.of(context).maybePop() so /now-playing dismisses
itself before the detail route is pushed. Result: clean navigation
into the destination, mini player visible underneath as expected.
Mini player keeps the default (no callback) since it's already in
the shell.
Server has been generating system_variant='discover' playlists since
M6a (internal/playlists/system.go and POST
/api/playlists/system/discover/refresh) but neither client surfaced
it. The Flutter home filtered system playlists by exact match on
'for_you' and 'songs_like_artist', dropping Discover. The web home
did the same. Tiles were generated server-side and silently
discarded by both clients.
Add a Discover slot to the playlists row in both:
- Flutter: 5-slot row now (For You, Discover, 3× Songs-like) with
matching placeholder state machine.
- Web: same shape, same placeholderVariant() call.
When the engine has built it, the real playlist tile renders;
otherwise a placeholder with the same building/pending/failed
status semantics as the other system tiles.
The lazy source-build commit (1ddde12) introduced a race: when the
user taps play on a new track while a previous queue's
_fillRemainingSources is still working, the stale fill keeps
calling _player.addAudioSource() on the new player state — appending
old-playlist tracks into the new queue and confusing the player into
the "locked to one song" symptom.
Fix: queue-generation counter. setQueueFromTracks bumps
_queueGeneration first thing; the background fill captures its gen
at start and aborts before any further player mutation if a newer
queue has taken over. The previous play() never gets to mutate the
new player state.
Also resets _suppressIndexUpdates at the start of every
setQueueFromTracks (defensive — covers the case where a prior
backward-fill bailed on its gen check before reaching `finally`)
and only releases the flag in finally if we're still the active
gen.
Symptoms this should resolve:
- "locked to one song" after rapid play taps
- Late `play() returned 73189ms` lines indicating a previous
hung play() call finally resolving and stepping on current state
- Player stuck in odd processingState after queue swaps
Same pattern as album + artist detail. PlaylistDetailScreen accepts
optional Playlist seed via go_router extra. While
playlistDetailProvider is still resolving, render the header
(description if any + track count) plus a "loading tracks…" inline
spinner instead of an opaque full-screen CircularProgressIndicator.
The body fills in when tracks arrive via drift watch re-emit.
Routing reads s.extra as Playlist. PlaylistCard +
PlaylistsListScreen pass the playlist via extra. Surfaces with no
seed available (deep links etc.) fall back to the original full-
screen spinner.
Track row rendering already uses ListView.builder so visible row
count was never the bottleneck — the wait was for the detail fetch
itself. Header-on-tap is the win that makes the screen feel snappy.
Two unrelated wins as a single batch.
Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.
New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.
Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.
Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
change rarely (re-scan, MBID enrichment); a day of cache is safe
and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
recompute when contents change; short enough for edits to feel
fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
are immutable for a given id (scanner re-indexes by file_path; new
files get new ids). LockCachingAudioSource on the Flutter side
already disk-caches, but proper headers let it skip even the
conditional 304 on repeat plays.
Logs were spitting "RenderFlex overflowed by 1.00 pixels on the
bottom" from album_card.dart whenever the library Albums tab or
artist detail album grid rendered. Cell height was computed as
cover + gap + title + artist + 4px slack, which assumes pixel-perfect
14sp/12sp line heights — Flutter's actual rendering with ascent/
descent + line-height multipliers wants one more pixel.
Bump slack from 4 to 8 in both grids. AlbumCard layout unchanged;
the warnings stop.
Otherwise the log shape is healthy now: prefetcher fires once per
library page emit (expected, one batch per pagination), cache misses
fetch sequentially with clean drift-write → re-emit cycles, and
album taps are single-round-trip cold-fetches followed by drift hits.
No more cycles of duplicate fetches.
Same fix shape as albums: drift's CachedPlaylistAdapter.toRef was
returning coverUrl: '' because the cache table doesn't persist the
server-derived URL. Set it to /api/playlists/<id>/cover in the
adapter — handleGetPlaylistCover serves the cached collage from
disk, so the URL is deterministic and the round-trip through drift
no longer drops it.
PlaylistCard + PlaylistsListScreen pass the existing queue_music
icon as ServerImage's fallback, so when the server hasn't built a
collage yet (system playlists with no tracks at build time), the
endpoint 404s and the icon shows over the slate background instead
of an empty box.
Tap→audio measured at ~370ms — that path's fast. Real symptom: a few
seconds of audio, then silence with no event surfaced to Flutter.
ExoPlayer is failing/completing somewhere and we have no log to act
on.
Three changes, all log-only:
- Add onError to playbackEventStream so stream failures (404, range-
request bugs, decoder errors, network drops) print instead of
silently halting playback.
- Subscribe to playerStateStream and log playing + processingState
on every transition. Silent stops will now show as a state shift
to completed / idle / buffering with no resumption.
- Subscribe to processingStateStream separately to catch fine-grained
state transitions ExoPlayer reports between source advances.
After hot-restart, tap-then-go-quiet should produce a sequence we
can read — most likely either "processingState=completed" partway
through (server returning premature EOS or wrong Content-Length) or
a thrown error from ExoPlayer's source-loading path.
CI fixes:
- artist_detail_screen.dart: drop unnecessary foundation import (debugPrint
comes from material) and unused metadata_prefetcher import.
Playback timing visibility (so we can stop guessing where the lag
lives):
- playTracks now logs serverUrl / token / configure / setQueue /
play() returned, each stage in milliseconds. The next time you
tap play, we'll see exactly where the seconds go.
- setQueueFromTracks adds two more measurements: total source-build
time across all tracks, and setAudioSources duration.
Small concrete win:
- audio_handler caches the application cache dir path on first use
(already cached in _maybeRegisterStreamCache; now also used in
_buildAudioSource for the LockCachingAudioSource path). One less
platform channel hit per track on cache-miss queue builds.
Once we see real numbers we can decide whether the fix is to build
sources lazily (initial source first → play → background-add the
rest), pre-warm the audio handler at app start so playTracks skips
serverUrl + token reads entirely, or something else.
The prefetcher + alwaysRefresh combination was creating a feedback
loop visible in the logs as repeated `metadataPrefetcher: warming N
albums` cycles, each kicking N parallel getAlbum fetches that then
triggered drift writes that triggered re-emits that re-ran the
prefetcher. Tap-to-play was queueing behind 14+ in-flight cache
fetches.
Three structural fixes:
1. Prefetcher hard-dedupes per session via _warmedArtists Set.
Re-rendering a screen no longer re-fires fetches for ids we've
already seen.
2. Prefetcher only warms artistProvider, not albumProvider. Albums
carry track lists; pre-warming N albums fans out N parallel
"fetch tracks" round trips for content the user may never visit.
Artist rows are single-row lookups — cheap. Album detail loads
on tap (still fast: server-side perf work makes it ~one round
trip).
3. Drop alwaysRefresh from albumProvider, artistProvider,
artistAlbumsProvider, artistTracksProvider. Each was kicking one
silent background refresh per first cache hit. With the prefetcher
creating many subscriptions in parallel, that meant every
prewarmed id triggered an extra fetch even when drift was already
populated. playlistsListProvider keeps alwaysRefresh — system
playlists genuinely rotate UUIDs and need the catch-up. Pull-to-
refresh remains the explicit invalidation path everywhere else.
Removed the warmAlbums calls from the library Albums tab and artist
detail album grid (the storm sources).
Net effect: cold app boot warms ~12-15 artist rows once, period.
Tapping a tile still fetches its detail on demand (one round trip,
fast). User-initiated playback isn't queued behind cache work.
MetadataPrefetcher gains warmAlbums(ids) / warmArtists(ids) public
methods so callers can fan out drift-cache warm-ups for whatever
collection just landed.
Wired into:
- Library Artists tab — warms first 8 artists from the page on every
data emit (initial + paginate + refresh).
- Library Albums tab — same for first 8 albums.
- Artist detail album grid — warms first 8 albums from the artist's
album list as soon as it loads, so tapping into any of them is a
drift hit.
Hard-cap of 8 per call (same as the home prefetch). Set spans across
calls aren't deduped at this layer because providers themselves
short-circuit on cached values.
Also instrument the artist-detail play button: try/catch around the
artistTracksProvider read + playTracks call, snackbar on
empty-tracks or thrown-error so silent failures stop being silent.
The current behavior was an early return on tracks.isEmpty with no
visible feedback.
Across-the-board sluggishness was every cold-cache tap doing one
network round trip while the user waits. SWR helps on re-visits but
the first time you tap a tile from home you eat the latency.
MetadataPrefetcher listens to homeProvider. When /api/home returns,
it fires fire-and-forget reads on albumProvider + artistProvider for
the top-N items in each home section (recently added, rediscover,
most played, last played). Each provider read triggers the existing
cold-cache path, which writes to drift. By the time the user
actually taps a tile, it's already a drift hit and the detail
screen renders instantly.
Cap N=8 per section (covers what's visible on a typical phone
without scrolling). Set spans dedupe across sections so popular
artists don't get fetched five times. Errors are swallowed — a
failed prefetch is silent and the tile falls back to its on-tap
fetch behavior.
Wired alongside the existing audio prefetcher in app.dart's
postFrameCallback.