The home screen renders solely from the per-item cached_home_index
path (proven on devices for many releases); the legacy snapshot stack
was dead weight.
- library_providers: remove homeProvider + _encodeHomeData +
_albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert
and models/home_data.dart imports
- metadata_prefetcher: re-point off homeIndexProvider/HomeIndex —
pre-warm artistProvider from the rediscover/last-played artist
sections (album/track tiles hydrate their own artist on render)
- live_events_dispatcher: homeProvider -> homeIndexProvider so live
events still refresh the home screen
- db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase
entry); schemaVersion 10->11; from<3 createTable -> raw
customStatement so the historical step compiles without the
generated symbol; from<11 DROP TABLE cached_home_snapshot
No test references the removed symbols. db.g.dart regenerated by CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.
Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.
track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator's model: offline, surface the cache-backed pools right
where the (now play-disabled, S4a) system playlists sit, so Home
still has something to play.
- ShuffleSource gains recentlyPlayed() (cache by lastPlayedAt desc,
liked included) and liked() (cache ∩ liked set); shared _refs()
materializes ordered ids from cached_tracks/artists/albums.
Shuffle-all reuses the same recency walk.
- Home Playlists row: when offlineProvider is true, prepend two
tiles — "Recently played" / "Liked" — sized to PlaylistCard.
Tap → shuffle+play that pool from cache; empty → snackbar.
System tiles still render (play disabled per S4a) beside them.
- offline=false (online + all widget tests) → no extra tiles;
placeholder-count tests unaffected.
Closes the S4 thread of the #427 umbrella (S4a + S4b).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.
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.
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.
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 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.
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>
#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>
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>
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>
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>
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.
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.
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.
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.
Both providers were FutureProvider<Paged<T>> returning a single page
of 50, so once the user scrolled past 50 items the list just ended.
Replace with AsyncNotifier<Paged<T>> that exposes loadMore(): fetches
the next page using items.length as the offset and appends to the
existing list. Idempotent guard via _loadingMore flag — concurrent
scroll events near the bottom collapse to a single fetch.
Both tabs now wrap the GridView in NotificationListener<ScrollNotification>
that fires loadMore() when within 800px of maxScrollExtent. The
lookahead is enough that the next page lands before the user hits
the visible bottom on a typical phone scroll.
Pull-to-refresh updated to invalidate + re-await the provider so a
manual refresh always starts from the first page.
- artist_detail_screen.dart missed an `import '../models/artist.dart'`
for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
parameters in <...> which lint reads as HTML. Switched to backticks.
Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
Artists tab 404: client called /api/library/artists, but the server
mounts the artists list at /api/artists (handleListArtists). Albums
sit at /api/library/albums for historical reasons — the paths aren't
symmetric. Switch listArtists() to /api/artists with sort=alpha.
Albums tab grid: matched the responsive 3-up layout we built for
artist detail. LayoutBuilder computes cellW from available width;
AlbumCard sized to the cell with titleMaxLines: 2; mainAxisExtent
matches actual content height (cover + 2-line title + artist line +
fudge). No more wide-aspect cells with empty space below the card.
Also wired `extra: ref` on the artists/albums grids and the Liked
tab so detail-screen nav hydration kicks in here too — taps from
library screens get the same instant header that home tiles do.
Three changes addressing the cold-start spinner + stale-on-revisit pain.
A. SWR on remaining cacheFirst providers
- artistProvider, artistAlbumsProvider, artistTracksProvider all gain
alwaysRefresh: true. Cache hit still renders instantly; one
background refresh per subscription keeps the row from going stale
forever.
- albumProvider (inline async*) and playlistDetailProvider (inline
async*) now keep a `revalidated` flag and kick a one-shot
background fetch on the first complete cache hit. Same effect as
cacheFirst's alwaysRefresh, just inline.
- Three providers gained the connectivity timeout that
album/artist/playlist already had.
B. Navigation hydration
- AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen
accepts `ArtistRef? seed`. When the live provider is still loading,
the seed populates cover/title/artist immediately so the page
isn't blank.
- Routing wires `extra: AlbumRef|ArtistRef` from go_router into
the seed parameter.
- Call sites updated: home (Recently added, Rediscover albums +
artists, Last played), artist detail album grid. Where a ref isn't
available (track actions sheet), the screen falls back to the
spinner — no regression.
C. Cold-start home skeleton
- Replace the full-screen CircularProgressIndicator on /home with a
layout-preserving skeleton: 5 section titles + 6 grey card-shaped
placeholders per row. The page feels populated immediately;
sections fill in independently as data arrives via the per-section
providers.
- Drops the unused DelayedLoading import.
Net effect: re-visits to detail screens render instantly (cache hit
+ silent refresh); first visit from a tile shows the seed header
immediately while tracks load; cold-start home shows a layout
skeleton instead of a 30s blank spinner.
artistAlbumsProvider populates cachedAlbums (album rows only) without
ever fetching their tracks. When the user taps from the artist grid
into an album, albumProvider sees a drift hit on the album row, never
triggers cold-cache, and yields with an empty track list — visible as
the album header rendering above an empty body.
Refactor albumProvider's cold-cache logic into a helper closure that
either branch (no album row OR album row + no tracks) can call. Add a
fetchAttempted guard so a server response that legitimately returns
zero tracks doesn't loop forever.
Decision flow:
- albumRows empty: cold-fetch (existing path), yield empty on
failure/offline
- albumRows hit but trackRows empty AND not yet attempted: same
cold-fetch — drift watch re-emits with the populated tracks
- albumRows hit, trackRows hit (or attempted already): yield as-is
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
numbers cramp.
Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
track in album detail; layout change wasn't the cause. Real cause:
albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
never cachedArtists. The drift JOINs that build the AlbumRef + each
TrackRef returned null for the artist row, so artistName fell back
to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
off the album header + each track, inserts them into cachedArtists
alongside the album/tracks. Subsequent JOINs populate artistName,
the audio handler stamps it on MediaItem, and the mini player picks
it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
re-fetched (cache invalidation TBD). Future cold-cache hits will.
Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
detail grid since the page header already names the artist. cellH
trimmed by the 16dp artist line.
AlbumCard gains optional `width` (default 140 keeps horizontal lists
unchanged) and `titleMaxLines` (default 1) so callers can let the
title wrap to a second line. Cover sizes to width - 16 instead of a
hardcoded 124, so the card scales down cleanly when a narrower cell
width is passed in.
Artist detail grid: switch to 3 columns. LayoutBuilder computes the
cell width from the available width so AlbumCard sizes to the cell
exactly (no overflow). cellH = cover + gap + 2-line title + artist
line + small fudge — tight enough that there's no visible gap below
the card, tall enough to fit a wrapped title.
Drift cache intentionally drops cover_url (server-derived). The
adapters comment says "REST cold-cache fallback briefly shows the real
values before drift takes over" — but once drift takes over, covers
are empty forever. Fix per source:
- Album cover: server constructs the URL deterministically as
/api/albums/<id>/cover (internal/api/convert.go:69). Mirror that
in CachedAlbumAdapter.toRef so AlbumRef.coverUrl is non-empty
whether the row came from a fresh fetch or a drift hit. Restores
cover art on the artist detail album grid (and any other surface
reading albums from drift).
- Artist cover: server picks a representative album and reuses its
cover (convert.go:98). Drift doesn't store the pointer, so derive
client-side via the artist's first loaded album. New _ArtistAvatar
prefers a non-empty server-emitted coverUrl and falls back to
/api/albums/<firstAlbumId>/cover, then slate while the album list
is still loading.
Album grid spacing was off because childAspectRatio: 0.8 inflated
each cell taller than the AlbumCard's actual ~160dp footprint,
leaving a visible gap below every card. Switch to mainAxisExtent: 168
with explicit 8dp main/cross spacing — cells now match the card and
sit on a clean grid.
Album detail screen rendered the cover slot as a bare slate Container
— no actual image. Wire ServerImage at /api/albums/<id>/cover (96dp,
rounded). Same for artist detail: ClipOval around ServerImage of the
artist's coverUrl, falling back to slate when empty.
Artist navigation hang: artistProvider goes through cacheFirst, which
had no logging — so we couldn't see whether the cold-cache fetch was
running. Add an optional `tag` parameter that, when set, debugPrints
each step (drift hit/miss, connectivity, fetch start/done/error).
Wire tags into artistProvider and artistAlbumsProvider, plus add the
3s connectivity timeout there too as belt-and-suspenders.
Next test pass should show cacheFirst[artist(<id>)]: lines that
pinpoint where the artist screen is sticking.
Two changes to surface where detail-screen spinners hang:
1. Connectivity().checkConnectivity() goes over a platform channel
that on some Android builds stalls. Wrap with a 2s timeout and
default to "online" if it times out — being wrong about
connectivity costs at most one failed fetch, but being stuck
spins the UI forever.
2. albumProvider's cold-cache flow now debugPrints at every step:
drift miss → connectivity result → API call → drift write → ack
the re-emit. Also adds a 10s timeout on the API fetch and a 3s
timeout on the connectivity await so a hang surfaces as an
error-yielded empty AlbumRef instead of an infinite spinner.
Once the operator's next test produces logs we can pinpoint which
step is hanging. The diagnostic prints stay until the issue's root
cause is confirmed; will be removed in a follow-up.
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.
Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:
- InkWell registers via the Material ripple system instead of the
raw gesture arena, sidestepping any subtle deferToChild edge cases
inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
visually — if the ripple shows but nothing navigates, the issue is
on the route side; if no ripple, the tap never landed.
Material color is transparent so the existing dark page background
shows through unchanged.
Same root cause as the playlist card fix in f65650f — GestureDetector
defers to children by default, so taps over the cover image area
(ServerImage / SVG fallback) didn't reliably propagate. Mark the
detector opaque so the entire card surface is tappable, not just the
text below the cover.
Player bar: drop the volume slider — phone hardware controls volume.
Right cluster collapses to a single row of shuffle/repeat/queue.
Playlist card: GestureDetector defers to children by default, so taps
over the cover image area didn't always register. Add HitTestBehavior.opaque
so the whole card surface is tappable.
Update banner: was 3-4× the height of its text — IconButton's default
48dp tap target plus generous vertical padding dominated. Tighten
padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim
the action button to 28dp height with shrinkWrap tap target.
Home screen: ClampingScrollPhysics + 140dp bottom padding (already
applied in working tree) so scroll stops just above the mini-player.
- cache_first.dart: backtick-fence List<T> doc comment to dodge
unintended_html_in_doc_comment.
- library_providers.dart:163: switch single-row insert to
insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict*
variant.
- 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>,
not Create<Future<T>>. Wrap data emissions in Stream.value(...).
- artist_detail_screen_test: add models/album.dart import for AlbumRef
type annotation.
- track_actions_sheet_test: drop _StubLiked extends LikedIdsController
(notifier no longer exists post-migration); override with
Stream.value(LikedIds(...)) instead.
- like_button_test: rollback assertion now requires drift writes via
LikesController. Skip under _skipDrift until libsqlite3-dev lands on
the runner image (Fable #399). Replace stale .notifier reference
with likesControllerProvider for completeness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three providers all become StreamProviders driven by drift watch():
- artistAlbumsProvider: cacheFirst over the join of cached_albums +
cached_artists for artist_name on each row.
- artistTracksProvider: cacheFirst over the join of cached_tracks +
cached_artists + cached_albums for snapshot fields.
- albumProvider: composite ({album, tracks}) shape, so uses async*
with two queries (album + tracks) for cleaner reactive behavior.
Cold-cache fallback inlined; populates both album and tracks tables
in one batch.
.future call site in artist_detail_screen.dart left as-is —
StreamProvider.future returns the next emission with the same
signature, so no consumer change needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CachedIndicator (lib/library/widgets/cached_indicator.dart) — small
download glyph rendered next to a track row when AudioCacheManager
reports the track as cached. FutureBuilder one-shot.
Wiring:
- track_row.dart: render CachedIndicator before the duration label
- playlist_detail: 'Download' OutlinedButton next to Play; pins all
playable tracks with source: autoPlaylist + SnackBar feedback
- album_detail: 'Download' IconButton in the header; same pin pattern
- app.dart: now ConsumerStatefulWidget — initState fires the initial
sync + activates the prefetcher provider
Together these complete the operator-facing surfaces of the offline
slice: visible cache state, explicit download trigger, automatic
sync + queue-ahead prefetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five terse "No X." strings replaced with the longer actionable forms
the web SPA uses on equivalent surfaces:
- Library Artists/Albums tabs: "No artists yet — scan a library folder
via the server's config." (mirrors web /library/artists, /albums)
- Library Liked tab: "No liked artists, albums, or tracks yet."
(consolidates web's per-section copy since Flutter shows all three
in one tab)
- Search no-results: "No matches for that query." (web has per-section
copy that doesn't fit Flutter's combined view)
- Discover no-results: "Nothing to add for that search yet." (mirrors
web /discover)
Hidden tab keeps its current actionable hint (web's "Nothing hidden
yet." is terser but lacks the "use a track's menu to flag" guidance).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional actions: bool = true param. When true (default), renders
the 3-dot menu trigger at the end of the row. Album detail / Search /
History / Liked tabs all consume TrackRow so they pick up the menu
transitively.
Pair with c8baaa5 which wired the button into the other 3 surfaces;
this commit completes the wire-up by including TrackRow itself (its
write was missed in the previous commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TrackRow gains an actions: bool = true param; renders the button
after duration. Album detail + Search + History/Liked tabs all
consume TrackRow so they pick up the menu transitively.
- CompactTrackCard (home Most-played) gets the button at the end of
its inner row.
- _PlaylistTrackRow in playlist_detail_screen gets the button next
to duration; skipped for unavailable rows (no track id).
- NowPlayingScreen renders the button next to the title with
hideQueueActions: true (Play next / Add to queue suppressed since
the menu's track IS the playing one). artistId is left empty since
it's not stashed in MediaItem.extras — Go-to-artist will route to
/artists/ which is benign for v1; a follow-up could stash it.
Closes Tier 1 follow-up #2 (track-level actions menu).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites HomeScreen body to match the web home layout:
- Playlists row: For-You + 3 Songs-like + user playlists, with
PlaylistPlaceholderCard for empty slots driven by
systemPlaylistsStatusProvider
- Recently added: 50 albums in 2 rows of 25 sharing a scroll controller
- Rediscover: separate scrollers for albums (square) and artists
(circular) — same condition as web's two-scroller branch
- Most played: 75 tracks in 3 rows of 25 using new CompactTrackCard
- Last played: existing single row layout preserved
Empty states use design-system understated-mythic copy mirrored
verbatim from web. Loading state uses DelayedLoading so brief network
blips don't flash a spinner.
Closes the visible parity gap the operator hit when first opening the
Flutter app on a real device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets multiple stacked HorizontalScrollRow instances render under a
single visible heading by passing title: "" to the second-row-onward
instances. Combined with the existing shared-controller support, this
gives us multi-row scrollers without a new widget.
Used in the next commit's home rewrite for Recently added (2 rows)
and Most played (3 rows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>