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>
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.
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.
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>
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.
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.
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.
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.
- 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>
ApiClient.buildDio takes on401; the library's dioProvider wires it to
the auth controller's clearSession. The router redirect already handles
navigation away from authed screens once the session goes null.
HomeScreen surfaces the banner on connection_refused with Retry +
Change URL.
LibraryApi wraps GET /api/home, /api/artists/{id}(/tracks),
/api/albums/{id}. dioProvider builds an authenticated dio (token
resolver reads session_token from secure storage on every request).
homeProvider, artistProvider(id), albumProvider(id) sit on top.