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: 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>
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.
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.
#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>
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>
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.
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.
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.
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>
HomeScreen drops its 5 ad-hoc IconButton actions in favour of the shared
widget. LibraryScreen gains an actions row it didn't have before. Both
now consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>