Operator: Shuffle-all belongs in the Library view, not the Home
app bar. Moved the shuffle IconButton to LibraryScreen's app bar
(same behavior — online server-random / offline cache-union via
shuffleSourceProvider); reverted Home's app bar to the original
MainAppBarActions-only and dropped the now-unused imports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online → GET /api/library/shuffle?limit=N (new): N random
library tracks server-side, per-user quarantine filtered
(ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
(audio_cache_index ∩ cached_tracks, names from cached_artists/
albums) — a UNION over liked AND recently-played, since the
two-bucket split is storage-only and never filters playback.
Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.
playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).
S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).
- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
recency for S4 + rolling LRU; migration backfills to cachedAt).
drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
partials (LockCaching files never indexed) as Rolling so the cap
truly bounds disk; evictBuckets() drains non-liked LRU then
sweeps orphans, Liked only by its own (large) cap — normal use
never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).
High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
offlineProvider: a Notifier<bool> driven by a periodic /healthz
probe. Offline after N=3 consecutive failed probes; recovers on
the first success. Slow heartbeat when online (30s), faster when
offline (10s) so recovery is noticed quickly. 5s initial delay for
provider warmup; optimistic (false) until proven otherwise.
Deliberately NOT coupled to connectivityProvider — subscribing to
that StreamProvider eagerly mounts its 2s timeout and leaks a
pending Timer through widget tests (the MutationReplayer bug).
/healthz failing already covers interface-down. No .timeout()
wrapper either (dio's own timeouts bound the probe) so the only
Timers are the tracked initial+periodic, both cancelled via
ref.onDispose — the proven smoke-safe shape.
Wired in app.dart postFrame to start the poller. No UI yet; S4
gates system-playlist play + Shuffle-all on it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.
- EventsApi.playOffline: single timestamp-preserving call → the new
/api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
- _beginTrack captures start context + fires live play_started;
the server id is adopted only if it lands while still on-track.
- position progress gated on the tracked track id so a track
change can't clobber the finishing track's last values.
- _closeCurrent: if a server id registered, attempt the live
ended/skipped and fall back to the offline queue on failure; if
no id (offline start) enqueue the completed play directly. The
server applies the canonical skip rule, so the offline payload
only carries duration.
- app paused/detached closes durably via the queue (survives a
process kill; a teardown POST would not).
Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.
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>
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.
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.
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.