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.
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.
Slice 1 of the cover-caching pass. The previous Image.network /
NetworkImage path only cached covers in memory, so a scroll-off + scroll-
back or an app restart re-downloaded every tile from the server. Swap
to cached_network_image so bytes land on disk (path_provider temp dir,
URL-keyed) and survive both.
Sites migrated:
- ServerImage (all /api/*/cover usage — home grid, library, playlist,
artist/album detail headers)
- DiscoverScreen Lidarr suggestion thumbnails
- PlayerBar mini cover (HTTPS branch; file:// branch unchanged since
AlbumCoverCache files are already on disk)
Auth header forwarding preserved via httpHeaders. Fade-in disabled so
populated grids paint instantly on cache hit.
Slice 2 (pre-warm during sync) builds on this same cache manager.
Drops the staleness gate from 1h to 1m and adds a Timer.periodic that
fires recheckIfStale every minute while the app is foregrounded. Net
effect: ~1 check per minute of active use, ~60 KB/hr data — trivially
affordable for the value of faster recovery when min_client_version
bumps server-side.
Timer is paired with the lifecycle observer: started in initState +
on resume, stopped in dispose + on pause/inactive/hidden/detached so
backgrounded apps don't burn battery on probes the user can't see.
Staleness gate still wraps the call so concurrent triggers (timer +
resume firing close together) dedupe to one network call. Manual
"Check now" still bypasses the gate via recheck().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-start spinner was up to ~38s on slow / remote connections
because VersionGate blocked the entire ShellRoute on /healthz, with
the default dio's 8s connect + 30s receive timeouts. The /healthz
server handler itself is fine (microsecond JSON encode); the blocker
was client-side. Three issues fixed in one pass:
1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget
that always renders its child and just activates the version
check controller on mount. The "you're too old" experience moves
from a full-screen hard-block (_TooOldScreen, deleted) to a soft
banner above the AppBar that lets the user keep playing cached
content while they update.
2. 1h-throttled background check. New VersionCheckController
(AsyncNotifier) hydrates from a secure-storage cache on boot,
returning the cached result instantly. If the cache is missing
or >1h old, fires a background recheck. AppLifecycleState.resumed
triggers recheckIfStale so foregrounding after >1h re-checks
without per-frame hammering. "Check now" button on the banner
bypasses the staleness gate so dev iteration (push new APK, want
to see banner clear) doesn't wait an hour.
3. Bounded health-check dio. The /healthz request uses a dedicated
dio with connectTimeout: 3s + receiveTimeout: 2s rather than the
default 8s / 30s. Health probes should fail fast — if the server
can't ack in 5s, the user has bigger problems than a stale
min_client_version and the cached value remains in effect.
Cache keys live alongside the existing tz cadence cache in
flutter_secure_storage (kResult + kAtMs). On any network error or
parse failure, _runCheck soft-fails without bumping the timestamp,
so the next staleness check will retry.
VersionTooOldBanner renders in _ShellWithPlayerBar's Column above
the existing UpdateBanner — the two coexist when both apply
(server rejects you AND an APK is queued).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated issues, batched.
Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.
Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
}
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.
Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.
Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
Same root cause as the /queue duplicate-page-key crash: /now-playing
is a top-level route (lives outside the ShellRoute), but
/artists/:id and /albums/:id are shell-children. Pushing a shell-
child from a top-level route makes go_router attempt to mount a
second ShellRoute on top of the active one, leaving navigation in a
broken state. The mini player works because it's already inside the
shell.
Add an optional onBeforeNavigate callback to TrackActionsSheet
(forwarded through TrackActionsButton). When set, fires after
sheet.pop() and before context.push() of the destination route.
Wire the full player's TrackActionsButton with onBeforeNavigate:
() => Navigator.of(context).maybePop() so /now-playing dismisses
itself before the detail route is pushed. Result: clean navigation
into the destination, mini player visible underneath as expected.
Mini player keeps the default (no callback) since it's already in
the shell.
Three issues, all related to the player surface:
1. Player UI didn't update on track change. audio_handler's
_onCurrentIndexChanged only kicked off the cover load — it never
pushed the new MediaItem onto the mediaItem stream. Title/artist/
cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
set on first play. Now the listener pushes queue[idx] when the
index changes.
2. Player kebab "Go to artist" 404'd while the same item from
MostPlayed worked. Same TrackActionsSheet for both, but the
player's _trackRefFromMediaItem was hardcoding artistId: ''
because audio_handler's _toMediaItem never stashed it in extras.
Stash artist_id alongside album_id; player_bar +
now_playing_screen read it back. Both kebabs now navigate.
3. "Start radio" didn't exist on Flutter even though the server has
/api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
fetches + plays the result via the existing playTracks path.
New menu item between "Add to playlist" and the divider above
"Go to album", calls startRadio with a snackbar error fallback.
The 401s on /api/albums/<id>/cover were the root cause of "only the
first tile is navigable" — failed images leaving slate placeholders
that combined with the deferToChild hit-test (fixed in d703fc2) to
silently swallow taps.
ServerImage:
- sessionTokenProvider is a FutureProvider; .value is null after a
hot restart until secure-storage resolves. Old code fired
Image.network with headers:null at that moment, the network image
cache locked in the 401 response, and never retried. Switch to
AsyncValue.when so the widget waits for the token before mounting
Image.network with the auth header attached.
- Add errorBuilder so a single failed image renders the parent
fallback instead of leaking a stack trace + broken-icon glyph.
Player bar:
- media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping
it in NetworkImage attempts HTTP on a file:// URI and fails. Use
FileImage when the scheme is file, else NetworkImage.
Net effect: failed image loads no longer leak errors or block other
widgets from rendering / receiving taps.
Two consumer updates left out of 8a6c926: like_button.dart and
track_actions_sheet.dart still referenced the removed
likedIdsProvider.notifier API. Switched to the new
likesControllerProvider.toggle().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal bottom sheet listing the caller's user-created playlists (system
playlists like For-You are filtered out — they're not user-mutable).
Tap a row to pop with the playlist id; consumer (TrackActionsSheet)
then calls PlaylistsApi.appendTracks.
Empty-state copy: "You haven't created any playlists yet."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal bottom sheet with five reason chips (mirroring server vocabulary
bad_rip / wrong_file / wrong_tags / duplicate / other) plus an optional
notes field. Returns (reason, notes) on Hide, null on Cancel. Default
selection is bad_rip.
Used by TrackActionsSheet's Hide action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-dot trigger and the modal-bottom-sheet menu it opens. 7 actions:
Play next, Add to queue, Like/Unlike, Add to playlist, Go to album,
Go to artist, Hide/Unhide. hideQueueActions: true suppresses the
first two for the Now Playing surface.
Sub-sheets (HideTrackSheet, AddToPlaylistSheet) land in subsequent
commits — this commit imports them speculatively, so CI between this
and the next two commits will fail. Resolved by Tasks 5 + 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from on-device testing against prod HTTPS:
1. ServerImage was resolving cover URLs correctly
(https://minstrel.fabledsword.com/api/albums/.../cover) but the server
returned 401 because Image.network doesn't carry the session token
automatically the way the browser sends cookies. Forwards the stored
session token as an Authorization: Bearer header. No-op when no token
is present.
2. The "Cleartext HTTP traffic to 127.0.0.1" audio error reproduced even
after the previous defensive check landed, which means the URL handed
to ExoPlayer has a valid scheme+host (just the wrong host). The check
only catches scheme-less URLs, so it didn't fire. Added debugPrint
logging at configure() and setQueueFromTracks() time to show the
actual baseUrl + per-track resolved URL on the next reproduction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover-art Image.network calls were passing server-relative URLs
(/api/albums/<id>/cover) straight to NetworkImage, which interprets
"no scheme" as file:/// and crashes with "No host specified in URI".
Same root cause regardless of HTTPS or HTTP server.
ServerImage wraps Image.network with a Riverpod read of
serverUrlProvider and prefixes the configured base URL. Absolute URLs
(e.g. discover screen's Lidarr image_urls) pass through unchanged.
Three call sites updated: album_card, artist_card, playlists_list_screen.
discover_screen left as-is — its row.imageUrl is already absolute (Lidarr
returns full URLs from MusicBrainz / Spotify metadata) and it has a
meaningful errorBuilder that ServerImage doesn't expose.
Also adds a defensive check in audio_handler.setQueueFromTracks: if
the constructed stream URL ends up scheme-less, throw a StateError
naming baseUrl + track.streamUrl + track.id instead of letting it
fall through to ExoPlayer which surfaces a confusing "Cleartext HTTP
traffic to 127.0.0.1 not permitted" error (Android's URL parser
defaults a scheme-less URI to localhost). User reported this exact
confusing error against an HTTPS prod server; the better message
will pinpoint where the empty baseUrl comes from on next reproduction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI caught six issues against the new admin slice:
- AsyncValue<T> in this Riverpod 3.3.1 codebase exposes `.value`
(returns T?), not `.valueOrNull`. Switched the three admin readers
to match the established convention (player_bar, queue_screen,
now_playing_screen all use `.value`).
- home_screen.dart still has ctx.push calls in _albumsRow / _artistsRow
(carousel tap handlers) — restored the go_router import that
Task 2 over-eagerly removed.
- admin_user_edit_sheet.dart had a single-line `if (!await ...) return;`
that violated curly_braces_in_flow_control_structures. Wrapped in
braces.
- admin_quarantine_item.dart doc comment had `<top>` placeholders that
the analyzer flagged as unintended HTML. Rephrased without angle
brackets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shared AppBar actions for top-level screens — three primary icons
(Home/Library/Search, current screen suppressed) plus a kebab containing
Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the
ad-hoc per-screen IconButton lists; centralises admin-gating logic in
one place so /admin entry can't accidentally leak to non-admins.
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.
Cold launch flow: no server-url -> /server-url. URL set, no token ->
/login. Token present -> /home with shell. VersionGate runs once when
the URL changes, hits /healthz, blocks if min_client_version > package
version. /home is a placeholder until Task 14.
Replaces Navigator.pushReplacementNamed in server_url_screen +
login_screen with context.go now that go_router owns the routes.