22 Commits

Author SHA1 Message Date
bvandeusen 369ed800b9 fix(flutter): non-blocking version check + 1h staleness throttle
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>
2026-05-13 16:14:42 -04:00
bvandeusen a3c0aed63e feat(flutter): playlist detail nav hydration — header renders before fetch
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.
2026-05-11 22:24:02 -04:00
bvandeusen c1df2af992 fix(flutter): /queue at top level — fixes duplicate-key crash from player
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.
2026-05-11 17:32:57 -04:00
bvandeusen 11c40c6aca feat(flutter): SWR everywhere + nav hydration + cold-start home skeleton
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.
2026-05-11 12:12:44 -04:00
bvandeusen 59427bcf2f fix(flutter): full player unmounts the shell — no doubled player UI
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.
2026-05-11 08:09:53 -04:00
bvandeusen 163c1174db feat(flutter): mini ↔ full player transition + flesh out NowPlayingScreen
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.
2026-05-11 08:06:38 -04:00
bvandeusen a05a279508 fix(flutter): strip duplicate status-bar inset when update banner shows
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.
2026-05-11 07:52:45 -04:00
bvandeusen 02d9f39845 feat(flutter): in-app update banner + Android install intent (#397 phase 2)
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>
2026-05-10 19:42:04 -04:00
bvandeusen 0db7054518 feat(flutter): user-facing requests view + cancel (#356)
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>
2026-05-10 18:08:23 -04:00
bvandeusen dfc08650e7 feat(flutter): wire admin routes + isAdmin gate
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>
2026-05-08 21:50:53 -04:00
bvandeusen 3f2822dfc6 feat(flutter): discover screen — search Lidarr + create requests
- models/lidarr.dart (LidarrSearchResult + LidarrRequestKind)
- api/endpoints/discover.dart (search + createRequest)
- discover/discover_screen.dart with TextField, kind toggle (Artists/Albums),
  results list with in_library / requested pills
- /discover route + explore icon on home AppBar (5 actions now: Library,
  Playlists, Search, Discover, Settings)
2026-05-08 14:53:06 -04:00
bvandeusen dfbeed01a6 feat(flutter): settings screen + 4-icon home AppBar
- models/my_profile.dart (MyProfile + ListenBrainzStatus)
- api/endpoints/settings.dart (profile / password / listenbrainz CRUD)
- settings/settings_screen.dart with three sections:
  - Profile: display_name + email, Save profile
  - Password: current + new + confirm, validates >=8 + match
  - ListenBrainz: token paste + scrobble-enabled toggle
- /settings route + Settings icon on home AppBar
- Home AppBar now: Library + Playlists + Search + Settings (was just Search)
2026-05-08 14:51:04 -04:00
bvandeusen da1cb7b590 feat(flutter): queue screen + skipToQueueItem support
- 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.
2026-05-08 14:42:10 -04:00
bvandeusen 66545bf8c3 feat(flutter): library screen with 5 tabs (Artists/Albums/History/Liked/Hidden)
Models:
- history_event.dart (HistoryEvent + HistoryPage envelope)
- quarantine_mine.dart (QuarantineMineRow for /api/quarantine/mine)
- Renamed Page<T> -> Paged<T> to avoid collision with Material's Page

Endpoints:
- library_lists.dart: listArtists / listAlbums (paged)
- me.dart: history + quarantineMine
- likes.dart: extended with listTracks/Albums/Artists (paged)

UI:
- library_screen.dart: TabBar over 5 tabs
- Artists tab: 3-col grid
- Albums tab: 2-col grid
- History tab: list with relative-time formatting (matches HistoryRow.svelte)
- Liked tab: 3 stacked sections (artists scroll-row, albums scroll-row, tracks list)
- Hidden tab: list of quarantined tracks with reason pill
- /library route + library_music icon on home AppBar

First page only for v1 (limit 50). Infinite scroll deferred.
2026-05-08 14:40:26 -04:00
bvandeusen 5541171e94 feat(flutter): playlists list + detail screens
- 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.
2026-05-08 14:33:53 -04:00
bvandeusen 147d6e280e feat(flutter): bump deps to latest + Search screen slice
Major version bumps (riverpod 2->3, go_router 14->17, just_audio 0.9->0.10,
flutter_secure_storage 9->10, google_fonts 6->8, flutter_lints 4->6).
package_info_plus held at 8.x due to win32 conflict with secure_storage 10.x.

Riverpod 3 breaks: AsyncValue.valueOrNull -> .value, StateProvider replaced
with Notifier subclass. just_audio 0.10: ConcatenatingAudioSource -> setAudioSources.

Search slice:
- models/page.dart (generic Page<T> envelope)
- models/search_response.dart (3-facet wrapper)
- api/endpoints/search.dart
- search/search_provider.dart (debounced 250ms)
- search/search_screen.dart (TextField + 3 horizontal/vertical sections)
- Wired /search route + search button on home AppBar
2026-05-08 14:30:01 -04:00
bvandeusen d86c694cde fix(flutter): five flutter analyze --fatal-infos issues from first CI run
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.
2026-05-02 19:01:54 -04:00
bvandeusen f675782bf5 feat(flutter/player): NowPlaying full-screen view with scrubber
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.
2026-05-02 17:40:13 -04:00
bvandeusen bbfeb5d3ff feat(flutter/player): mini PlayerBar in shell + tap-to-play wiring
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.
2026-05-02 17:39:09 -04:00
bvandeusen 6ec7cea362 feat(flutter/library): artist + album detail screens
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.
2026-05-02 17:32:34 -04:00
bvandeusen 5ef782905e feat(flutter/home): home screen with five sections
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.
2026-05-02 17:30:42 -04:00
bvandeusen d115c267f4 feat(flutter): GoRouter shell + version gate + auth-aware redirects
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.
2026-05-02 17:24:08 -04:00