Same pattern as album + artist detail. PlaylistDetailScreen accepts
optional Playlist seed via go_router extra. While
playlistDetailProvider is still resolving, render the header
(description if any + track count) plus a "loading tracks…" inline
spinner instead of an opaque full-screen CircularProgressIndicator.
The body fills in when tracks arrive via drift watch re-emit.
Routing reads s.extra as Playlist. PlaylistCard +
PlaylistsListScreen pass the playlist via extra. Surfaces with no
seed available (deep links etc.) fall back to the original full-
screen spinner.
Track row rendering already uses ListView.builder so visible row
count was never the bottleneck — the wait was for the detail fetch
itself. Header-on-tap is the win that makes the screen feel snappy.
Tapping the queue button from /now-playing crashed with
NavigatorState._debugCheckDuplicatedPageKeys. Root cause: when I
moved /now-playing out of the ShellRoute earlier, /queue was left
inside the shell. Pushing /queue while /now-playing was on top
made go_router try to mount a second ShellRoute instance under the
existing one — both shells got the same page key.
Move /queue out of the ShellRoute too, sibling to /now-playing.
Shell stays mounted (with whatever child it had) underneath both
top-level routes; pop from /queue returns to /now-playing or the
shell's previous child as appropriate.
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.
You correctly diagnosed: the mini bar was still visible underneath
the full player, and that's also why the bottom controls overflowed
by 45px (the shell's mini bar was eating the bottom 88dp).
Move /now-playing OUT of the ShellRoute and up to a top-level GoRoute.
Now pushing /now-playing unmounts the shell entirely — no mini bar
underneath, no fight for the bottom strip. The slide-up transition
still feels right because the shell stays painted for the duration of
the animation (so during the transition both are briefly visible, as
you noted is fine).
Drop the hideMini conditional in _ShellWithPlayerBar — no longer
needed since the shell isn't even built when the full player is on
top. Recovering the 88dp also resolves the bottom-controls overflow
without needing to shrink the layout.
Mini player simplifies to track-info + prev/play/next. The
shuffle/repeat/queue cluster moves to the full player, where it has
the room to breathe.
Full player (NowPlayingScreen) now has:
- Real album art (FileImage when artUri is file://, ServerImage from
/api/albums/<id>/cover otherwise — same auth-aware loader as the
rest of the app, with errorBuilder so a failed fetch falls back to
a slate placeholder instead of a broken-image glyph).
- Title row with inline like + kebab.
- Full-width seek with start/end timestamps below.
- 36/72/36 prev/play/next.
- Secondary row: shuffle, repeat, queue.
Mini ↔ Full transition mechanics:
- Tap mini → push /now-playing with custom slide-up transition
(CustomTransitionPage, 280ms easeOutCubic).
- Vertical drag-up flick on mini (>200 px/s) → same push.
- System back / leading expand-more icon → Navigator.pop, which the
CustomTransitionPage replays as a slide-down (240ms easeInCubic).
- Drag-down on full player past 80px OR a >500 px/s downward flick
→ Navigator.pop. Buttons and the seek slider stay tappable since
Flutter's gesture arena gives priority to the more-specific child
recognizers.
- Mini player hides while /now-playing is the active route — the
full player IS the player UI in that state, no need to fight over
visual space.
You correctly diagnosed the symptom: the AppBar gets taller when the
banner is present. Why: the shell is [Banner, Expanded(child),
PlayerBar] and the banner uses SafeArea(bottom:false) to clear the
status bar. But MediaQuery.padding is screen-relative — the child's
Scaffold/AppBar reads MediaQuery.padding.top and applies its own
status-bar inset on top of the banner, doubling the gap.
Watch shouldShowUpdateBannerProvider in the shell. When it returns
non-null (banner visible), wrap the child in MediaQuery.removePadding
removeTop: true so the AppBar mounts directly under the banner. When
the banner is hidden, child gets its normal top inset.
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.
### Dart side
- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
polling on app start + every 24h. Compares semver via pub_semver
with non-semver string-equality fallback. Server 404 = "no update
channel" = silent. Companion shouldShowUpdateBannerProvider gates
on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
downloading (with progress) / error stages. Mounted in
_ShellWithPlayerBar above the route content; SizedBox.shrink()
when no update available so non-shell routes (login, server-url)
see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
semver, prerelease ordering, leading-v normalization, and
non-semver fallback (date-tag style).
### Android side
- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
downloaded APK can be served via content:// URI (file:// is
blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
so the system installer can read across the process boundary.
Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.
- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
cancel(id). Same /api/requests endpoint the web /requests page
uses; identical AdminRequest wire shape so the existing model
is reused (just augmented with matched_*_id fields for the
"Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
already shipped server-side / web-side): 12s refresh while any
row is mid-ingest (status='approved'), stops on settle. Riverpod
Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
the Cancel-confirm dialog.
Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /admin, /admin/requests, /admin/quarantine, /admin/users routes to
the ShellRoute (so PlayerBar persists). Redirect closure refuses any
/admin path when user.isAdmin is false. Server-side RequireAdmin
middleware remains the actual authority — this is UX.
Screens land in subsequent commits; this intermediate state won't build
until task 8/9 lands the AdminLandingScreen and the missing siblings
are stubbed out by task 11/13/16.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- audio_handler: override skipToQueueItem(index) -> _player.seek(zero, index: i)
- player/queue_screen.dart: list of MediaItems with current track highlighted
(left accent border + Now Playing badge), tap-to-jump on non-current rows
- /queue route + queue_music icon on /now-playing AppBar
Reorder + remove deferred for v1.
- models/playlist.dart (Playlist, PlaylistTrack, PlaylistDetail)
- api/endpoints/playlists.dart (list with kind=user|system|all, get detail)
- playlists/playlists_provider.dart (Riverpod family providers)
- playlists/playlists_list_screen.dart (with system-variant pill)
- playlists/playlist_detail_screen.dart (header + Play button + rows)
- /playlists + /playlists/:id routes wired
- Home AppBar: queue_music icon next to Search
Tracks with track_id=null render greyed-out + struck-through (matches
web's PlaylistTrackRow behavior). Tap a row plays from that position;
top-level Play button plays the full playable subset from track 0.
1. lib/app.dart + lib/shared/routing.dart — buildRouter takes a Ref but was
being called from a ConsumerWidget's build() with a WidgetRef. Add a
routerProvider; the widget watches it instead of constructing the
router from its own ref. Real bug — would have crashed compile in
slice 1, just never compiled until CI ran.
2. lib/player/audio_handler.dart — override of AudioHandler.seek used
`p` for the Duration; rename to `position` to match the base class
(avoid_renaming_method_parameters lint).
3. lib/theme/theme_extension.dart — fromTokens() returned a non-const
constructor; all inputs are const so make the call const too
(prefer_const_constructors).
4. test/library/home_screen_test.dart — same const-constructor lint on
the HomeData test fixture.
5. test/smoke_test.dart — drop the unused
`import 'package:flutter/material.dart'`.
These are exactly the kind of issues the no-in-task-tests rule shifted
to CI; this is the first run that actually exercises the analyzer
against the slice 1 code.
Reached by tapping the PlayerBar. Reads media + playback streams; seek
dispatches through the audio handler. Swipe-down dismiss is a stand-in
chevron-down AppBar leading icon for v1.
PlayerBar reads playbackState + mediaItem streams; hidden when no
queue. Home track-row tap, album play button, album track tap, artist
play button (shuffle) all route to playerActions.playTracks. Token +
baseUrl forwarded to the audio handler before each new queue.
Headers carry placeholder play buttons (wired in Task 18) + room for
LikeButton (Task 16). Artist screen has a 2-column album grid; album
screen has a track list with mm:ss durations. Routes :id-parameterized
under the shell so the PlayerBar persists.
Sections mirror the web home: Recently added · Rediscover (albums +
artists) · Most played · Last played artists. Tap routes pushed for
albums/artists; track-tap is wired in the player task. Pull-to-refresh
re-fetches /api/home.
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.