Commit Graph

91 Commits

Author SHA1 Message Date
bvandeusen 5114a8118c feat(flutter): CachedIndicator + Download buttons + app startup wiring
CachedIndicator (lib/library/widgets/cached_indicator.dart) — small
download glyph rendered next to a track row when AudioCacheManager
reports the track as cached. FutureBuilder one-shot.

Wiring:
  - track_row.dart: render CachedIndicator before the duration label
  - playlist_detail: 'Download' OutlinedButton next to Play; pins all
    playable tracks with source: autoPlaylist + SnackBar feedback
  - album_detail: 'Download' IconButton in the header; same pin pattern
  - app.dart: now ConsumerStatefulWidget — initState fires the initial
    sync + activates the prefetcher provider

Together these complete the operator-facing surfaces of the offline
slice: visible cache state, explicit download trigger, automatic
sync + queue-ahead prefetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:23:26 -04:00
bvandeusen fd4ce402d7 feat(flutter/settings): Storage section UI — usage, cap, prefetch, controls
New StorageSection widget mounted in SettingsScreen between Appearance
and Password sections. Surfaces:

  - Cache usage display (live, refreshed on cap change + on Clear)
  - Cache size limit dropdown (1 / 5 / 10 / 25 GB / Unlimited)
  - Pre-fetch ahead dropdown (1 / 3 / 5 / 7 / 10 tracks)
  - Cache liked tracks switch
  - Clear cache button (confirm dialog, then clearAll)
  - Sync now button (triggers SyncController.sync(), spinner while inflight)

Cap change triggers immediate eviction. Clear cache wipes EVERYTHING
(manual-pinned tracks too) per the operator-facing copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:21:34 -04:00
bvandeusen 153e931490 feat(flutter/player): audio_handler reads from cache first; LockCaching fallback
_buildAudioSource is now async and cache-aware:
1. Cache hit  → AudioSource.uri(file://path)
2. Cache miss with manager → LockCachingAudioSource (cache-as-you-play)
3. No manager configured → plain AudioSource.uri (legacy fallback)

playerActionsProvider.playTracks now passes audioCacheManager into
configure() alongside coverCache. setQueueFromTracks awaits the source
build (Future.wait over the track list).

Out of scope: registering an index row when LockCachingAudioSource
finishes downloading (no clean hook from just_audio). Prefetcher /
Download buttons cover the index path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:20:24 -04:00
bvandeusen 536350380f feat(flutter/cache): Prefetcher — queue-ahead pre-download window
Listens to mediaItemProvider + cacheSettingsProvider. On track change
(or settings change), computes the [currentIdx, currentIdx + N] window
in the queue and pins each uncached track via AudioCacheManager with
source: autoPrefetch.

Window N comes from cacheSettingsProvider.prefetchWindow (default 5,
configurable in Settings → Storage card).

Smoke-test only at the unit level — real coverage via on-device pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:19:01 -04:00
bvandeusen ab58f3ffcb feat(flutter/cache): SyncController — token-based delta sync
Drives /api/library/sync against drift:
  - reads cursor from SyncMetadata (default 0 = full snapshot)
  - 204 → just bump lastSyncAt, return zeroes
  - 410 → wipe all cached entities + retry from cursor=0
  - 200 → apply upserts + deletes per entity type, advance cursor

Handles all 8 entity types (artist/album/track + like_track/like_album/
like_artist + playlist/playlist_track) for both upsert and delete paths.
Composite-key entities use the "<a>:<b>" string format the server emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:18:15 -04:00
bvandeusen 16669a0365 feat(flutter/cache): AudioCacheManager — pin/unpin/evict + tiered LRU
API:
  - isCached(trackId), pathFor(trackId)
  - pin(trackId, source) — dio.download → drift index entry
  - unpin(trackId), clearAll()
  - usageBytes(), evict(targetBytes)

Eviction order: incidental → autoPrefetch → autoPlaylist → autoLiked.
'manual' never evicts via cap-driven eviction; only clearAll() removes
operator-pinned tracks.

Two providers:
  - appDbProvider — singleton AppDb per run
  - audioCacheManagerProvider — wraps the AppDb + dioProvider

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:17:14 -04:00
bvandeusen 2bcbb53c49 feat(flutter/cache): connectivityProvider + cacheSettingsProvider
connectivityProvider — StreamProvider<bool>; true when any connectivity
result is non-none. No Wi-Fi gate per operator decision.

cacheSettingsProvider — AsyncNotifier<CacheSettings> persisting
capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage.
Defaults: 5 GB cap, 5-track prefetch, cache liked = on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:15:17 -04:00
bvandeusen 326b7008a9 feat(flutter/cache): drift schema + new deps + CI codegen step (#357 plan B)
Adds the offline-mode foundation:
- pubspec deps: drift, drift_flutter, sqlite3_flutter_libs,
  connectivity_plus + drift_dev/build_runner (dev)
- lib/cache/db.dart with 8 tables: CachedArtists/Albums/Tracks/Likes/
  Playlists/PlaylistTracks + AudioCacheIndex + SyncMetadata
- *.g.dart added to .gitignore (build_runner regenerates per build)
- CI workflow gains a 'Codegen (drift)' step between pub get and
  analyze so the generated symbols exist when analyze runs

CacheSource enum drives tiered LRU eviction (manual/autoLiked/
autoPlaylist/autoPrefetch/incidental). Subsequent commits add the
sync controller, audio cache manager, prefetcher, settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:07:34 -04:00
bvandeusen 84a638a41f fix(flutter): clear analyze --fatal-infos issues
Three classes:

1. album_cover_cache.dart docstring used <applicationCacheDirectory>
   and <albumId> which the analyzer reads as unintended HTML. Wrapped
   the path in backticks and used {} placeholders.

2. settings AppearanceSection used RadioListTile.groupValue + onChanged,
   deprecated in Flutter 3.32+. Wrapped the radios in a RadioGroup
   ancestor (the new API) so individual tiles only declare value +
   activeColor. Test updated to read groupValue off the RadioGroup
   ancestor.

3. theme_extension.dart factories built FabledSwordTheme(...) without
   const, even though all args are static const tokens. Added const to
   the outer constructor calls in both .dark() and .light() — this
   propagates const to the inner TextStyle(...) calls automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:16:35 -04:00
bvandeusen dd848e76e8 feat(flutter/playlists): empty-state hint on empty playlist detail
When a playlist has zero tracks, the detail screen previously showed
just the header with no indication of what to do next. Adds an inline
hint mirroring the web's copy: "No tracks yet. Add some via the
\"Add to playlist…\" entry on any track row."

Implementation: itemCount goes from tracks.length + 1 to
(tracks.isEmpty ? 2 : tracks.length + 1) and the builder emits the
hint at index 1 when tracks is empty. Header still renders at index 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:07:41 -04:00
bvandeusen 0dbb662b4c feat(flutter): search no-results copy mirrors design-system voice
Follow-up to ccebd98 — the search_screen edit failed in that commit
because the file hadn't been read in the session. "No matches." →
"No matches for that query." Same scope as the larger sweep commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:07:19 -04:00
bvandeusen ccebd98735 feat(flutter): empty-state copy mirrors web design-system voice
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>
2026-05-09 16:06:58 -04:00
bvandeusen 231f1ddf9a test(flutter/theme): cover .dark()/.light() factories + back-compat alias
Four cases: dark factory pulls from FabledSwordDarkTokens, light from
FabledSwordLightTokens, flat tokens (accent / moss / etc.) are shared
between both, and the fromTokens() back-compat alias returns the dark
theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:51 -04:00
bvandeusen 143130eaae feat(flutter/settings): Appearance section with System/Light/Dark picker
Three RadioListTiles between Profile and Password sections. Tap →
themeModeProvider.notifier.set(...) → MaterialApp rebuilds with the
new theme via the existing themeMode wiring.

System has the only subtitle ("Match the device setting") — the
light/dark options are self-explanatory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:50:29 -04:00
bvandeusen 56f0557d94 feat(flutter): MinstrelApp watches themeMode + offers darkTheme
MaterialApp now provides both light and dark themes plus a
themeMode bound to themeModeProvider. ThemeMode.system rebuilds
the tree automatically when the OS toggles brightness — no manual
listener needed.

While themeModeProvider is loading (storage read in flight), falls
back to AppThemeMode.system, which inherits the OS setting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:41 -04:00
bvandeusen 6dc488652e feat(flutter/theme): themeModeProvider with secure-storage persistence
AsyncNotifier wrapping flutter_secure_storage's "theme_mode" key.
Returns AppThemeMode.system as the default. set(mode) writes the
value + updates state.

Used by MinstrelApp (next commit) to drive MaterialApp.themeMode and
by the Settings appearance section to persist the operator's pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:00 -04:00
bvandeusen caa19f2d91 feat(flutter/theme): buildDarkTheme + buildLightTheme
Both builders construct a ThemeData with the matching brightness +
ColorScheme.dark/.light + the corresponding FabledSwordTheme
extension. buildThemeData() stays as a back-compat alias returning
the dark theme so the existing widget-test suite keeps using it
without churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:37 -04:00
bvandeusen 66ddd3c366 feat(flutter/theme): FabledSwordTheme.dark() + .light() factories
Both factories produce the same field shape (semantic role-based
names like obsidian/iron/parchment) with palette values from the
new FabledSwordDarkTokens or FabledSwordLightTokens generated
classes. fromTokens() stays as a back-compat alias returning the
dark theme so existing call sites keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:01 -04:00
bvandeusen b1f62a0733 feat(flutter/theme): codegen emits dark + light + flat token classes
gen_tokens.dart now produces FabledSwordDarkTokens (dark surface),
FabledSwordLightTokens (light surface), and FabledSwordFlatTokens
(colors that don't change + radii + fonts). The original
FabledSwordTokens class stays as a back-compat alias re-exporting
the dark surface + flat values; any code that reads
FabledSwordTokens.obsidian etc. continues to work during migration.

Source: shared/fabledsword.tokens.json (already had dark/light/flat
splits — this was the generator catching up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:45:40 -04:00
bvandeusen 25ff7afc33 feat(flutter): TrackRow renders TrackActionsButton by default
Adds optional actions: bool = true param. When true (default), renders
the 3-dot menu trigger at the end of the row. Album detail / Search /
History / Liked tabs all consume TrackRow so they pick up the menu
transitively.

Pair with c8baaa5 which wired the button into the other 3 surfaces;
this commit completes the wire-up by including TrackRow itself (its
write was missed in the previous commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:43:40 -04:00
bvandeusen c8baaa5329 feat(flutter): wire TrackActionsButton into 4 surfaces
- TrackRow gains an actions: bool = true param; renders the button
  after duration. Album detail + Search + History/Liked tabs all
  consume TrackRow so they pick up the menu transitively.
- CompactTrackCard (home Most-played) gets the button at the end of
  its inner row.
- _PlaylistTrackRow in playlist_detail_screen gets the button next
  to duration; skipped for unavailable rows (no track id).
- NowPlayingScreen renders the button next to the title with
  hideQueueActions: true (Play next / Add to queue suppressed since
  the menu's track IS the playing one). artistId is left empty since
  it's not stashed in MediaItem.extras — Go-to-artist will route to
  /artists/ which is benign for v1; a follow-up could stash it.

Closes Tier 1 follow-up #2 (track-level actions menu).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:43:12 -04:00
bvandeusen 36006d703d feat(flutter/track-actions): AddToPlaylistSheet picker
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>
2026-05-09 13:42:19 -04:00
bvandeusen 2944288050 feat(flutter/track-actions): HideTrackSheet for reason + notes pick
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>
2026-05-09 13:41:48 -04:00
bvandeusen 5f239f05a5 feat(flutter/track-actions): TrackActionsButton + TrackActionsSheet
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>
2026-05-09 13:41:16 -04:00
bvandeusen 2499449c0b feat(flutter/player): playNext + enqueue + PlaylistsApi.appendTracks
Three small data-layer additions for the upcoming track-actions menu:

- PlaylistsApi.appendTracks(playlistId, trackIds) wraps
  POST /api/playlists/{id}/tracks for the "Add to playlist" action.
- audio_handler gains playNext (insertAudioSource at currentIndex+1)
  and enqueue (addAudioSource) — both also push the audio_service
  queue notifier so the queue-screen UI stays in sync.
- The AudioSource construction was extracted into a private
  _buildAudioSource helper so setQueueFromTracks / playNext / enqueue
  share one source-building path.
- PlayerActions exposes playNext / enqueue for menu use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:40:17 -04:00
bvandeusen 5159bcd3f4 feat(flutter/quarantine): MyQuarantineController + provider
AsyncNotifier wrapping /api/quarantine/mine with optimistic flag /
unflag mutations and an isHidden(trackId) convenience for menu state.
Mirrors the LikedIdsController pattern: optimistic update, rollback
on server error.

Used by the next slice's TrackActionsSheet to power Hide / Unhide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 -04:00
bvandeusen fdaa1a0472 feat(flutter/api): QuarantineApi for flag/unflag
Dio client for /api/quarantine — flag a track with a reason + optional
notes, unflag by track id. Mirrors the server's user-scoped quarantine
endpoints (separate from /admin/quarantine).

Used by the track-actions menu's Hide/Unhide action in the next slice
of commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:38:52 -04:00
bvandeusen 534dafb044 feat(flutter/player): set MediaItem.artUri from local cover cache
Audio handler accepts an AlbumCoverCache via configure() and uses it
to populate MediaItem.artUri with a file:// URI to a locally cached
album cover. Lock screen, Bluetooth car displays, Wear OS, and CarPlay
now show the album cover for the currently playing track instead of
the system's generic music icon.

Flow:
- _toMediaItem stashes album_id in MediaItem.extras
- After setQueueFromTracks pushes the queue + initial mediaItem, fires
  _loadArtForCurrentItem async (doesn't block playback)
- Subscribes to _player.currentIndexStream so track advances trigger
  the same loader for the new current item
- _loadArtForCurrentItem early-returns if cache is null, no current
  item, no album_id, or artUri already set; otherwise calls
  cache.getOrFetch and pushes an updated MediaItem with artUri set
- Race guard: if the user skipped to another track while the fetch was
  in flight, the result is discarded

Closes the visible "generic music icon on lock screen" gap operator
flagged when first-testing on a real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:54:47 -04:00
bvandeusen d70075c426 feat(flutter/player): albumCoverCacheProvider + pass cache through configure
Adds the Riverpod provider (constructed with dioFactory pulling from
the authenticated dioProvider) and extends PlayerActions.playTracks
to forward the cache to audio_handler.configure(). The handler
signature change lands in the next commit.

Intentional intermediate-broken state: audio_handler.configure
doesn't yet accept coverCache. Resolved by the next task in the same
push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:54:05 -04:00
bvandeusen b6d6f22598 feat(flutter/player): AlbumCoverCache for lock-screen artwork
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.

Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.

No eviction — covers are tiny, OS clears cache when space is tight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:20 -04:00
bvandeusen 78ad8a31e7 chore(flutter): promote path_provider to direct dep
Already a transitive dep via flutter_secure_storage and flutter_cache_manager.
Promoting to direct so we can import it from album_cover_cache (next
commit) for the lock-screen artwork cache directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:52:44 -04:00
bvandeusen d3a05520d3 fix(flutter): clear analyze --fatal-infos issues
Three info-level lints from CI:

- playlist_card.dart doc comment: "/playlists/<id>" → "/playlists/{id}"
  in backticks, since `<id>` was flagged as unintended HTML.
- compact_track_card_test.dart: home: Scaffold(...) is now const, which
  propagates const to CompactTrackCard and makes the inner [_track]
  implicitly const.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:05:02 -04:00
bvandeusen 17ff69780d test(flutter/home): cover new home parity layout
Expands the existing single test into four cases:
- 4 placeholder cards in Playlists row when no system playlists exist
- Empty-state copy for each section verbatim against the strings
  in HomeScreen
- Real For-You card renders with the system badge when present
- Existing rollups (Recently added / Rediscover) still render correctly
  alongside the new providers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:40:46 -04:00
bvandeusen a2f6bdb44f feat(flutter/home): mirror web home structurally
Rewrites HomeScreen body to match the web home layout:
- Playlists row: For-You + 3 Songs-like + user playlists, with
  PlaylistPlaceholderCard for empty slots driven by
  systemPlaylistsStatusProvider
- Recently added: 50 albums in 2 rows of 25 sharing a scroll controller
- Rediscover: separate scrollers for albums (square) and artists
  (circular) — same condition as web's two-scroller branch
- Most played: 75 tracks in 3 rows of 25 using new CompactTrackCard
- Last played: existing single row layout preserved

Empty states use design-system understated-mythic copy mirrored
verbatim from web. Loading state uses DelayedLoading so brief network
blips don't flash a spinner.

Closes the visible parity gap the operator hit when first opening the
Flutter app on a real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:40:12 -04:00
bvandeusen 8fb82d473d feat(flutter/widgets): HorizontalScrollRow skips header when title empty
Lets multiple stacked HorizontalScrollRow instances render under a
single visible heading by passing title: "" to the second-row-onward
instances. Combined with the existing shared-controller support, this
gives us multi-row scrollers without a new widget.

Used in the next commit's home rewrite for Recently added (2 rows)
and Most played (3 rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:57 -04:00
bvandeusen a7fcafadda feat(flutter): DelayedLoading wrapper for skeleton-with-delay
Renders nothing for the first [delay] (default 200ms) of sustained
loading, then renders the [whileDelayed] child until loading completes.
Mirrors the web useDelayed hook so a fast network response doesn't
flash a skeleton.

Used in the next commit's home screen rewrite; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:42 -04:00
bvandeusen 3ac8cc9363 feat(flutter/widgets): PlaylistCard + PlaylistPlaceholderCard
Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.

PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"

Used in the next commit's home Playlists row; otherwise unused this
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:19 -04:00
bvandeusen dccdb00bce feat(flutter/widgets): CompactTrackCard for dense home Most-played row
Mirrors the web CompactTrackCard — small horizontal cell with cover
art (48dp), title, artist. Tap plays from this track in the section.
Width 176dp matches web's compact density so 25 tracks per row scroll
naturally on phone widths.

Cover URL is derived from track.albumId (/api/albums/<id>/cover) since
TrackRef itself doesn't carry a cover field — same pattern as web's
coverUrl() helper.

Used in the next commit's home Most-played section (3 rows × 25);
otherwise unused this slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:37:40 -04:00
bvandeusen 078520f504 feat(flutter): systemPlaylistsStatusProvider
Riverpod FutureProvider that reads the caller's system-playlists
build state. Consumed by the home Playlists row to decide between
real and placeholder cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:36:29 -04:00
bvandeusen d872a30506 feat(flutter): SystemPlaylistsStatus model + endpoint method
Adds the model + MeApi.systemPlaylistsStatus() for the home Playlists
row's placeholder-variant logic (building / failed / pending /
seed-needed). Mirrors GET /api/me/system-playlists-status which
returns the caller's most recent system_playlist_runs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:36:15 -04:00
bvandeusen 5613fec5ef fix(flutter): PlaylistsApi.list parses {owned, public} envelope
The server has always returned an enveloped response from GET
/api/playlists; the existing client decoded it as a flat List which
fails at runtime against the actual Map shape. Existing playlists
list screen would have been broken on any production instance.

list() now returns PlaylistsList { owned, public }. The existing
list screen consumes lists.all (owned + public flattened) — same
visible output as the previous flat-list assumption.

This unblocks the home parity slice which needs the same endpoint
for the new Playlists row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:35:34 -04:00
bvandeusen 28b07c7ef4 fix(flutter/android): permit cleartext to 127.0.0.1 for just_audio loopback proxy
Audio playback failed against the prod HTTPS server with
"Cleartext HTTP traffic to 127.0.0.1 not permitted". Diagnostic
logging confirmed _baseUrl was correctly set to the prod URL — the
127.0.0.1 wasn't coming from the server or our URL construction.

Root cause: just_audio + audio_service spin up a local HTTP proxy on
loopback to inject the Authorization header, because Android's
MediaSession API can't pass headers down to ExoPlayer directly.
ExoPlayer connects to http://127.0.0.1:<random-port>; the proxy adds
the Bearer header and forwards to the real upstream HTTPS URL.
Loopback traffic doesn't leave the device.

This is the documented just_audio happy path — see
https://pub.dev/packages/just_audio#a-note-on-android-cleartext-traffic
— not a workaround for a plugin bug.

Adds network_security_config.xml that scopes cleartext permission to
127.0.0.1 ONLY. The base config keeps cleartextTrafficPermitted=false
so any actual remote endpoint must still be HTTPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:24:47 -04:00
bvandeusen 83df3773ae fix(flutter): cover URLs send Bearer auth + audio handler diag logging
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>
2026-05-08 23:15:56 -04:00
bvandeusen 602ef3bfdf fix(flutter): cover URLs resolve against server baseUrl + audio stream URL guard
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>
2026-05-08 22:47:25 -04:00
bvandeusen 751c2878f7 fix(flutter/android): production manifest + AudioServiceActivity
Two related Android setup gaps caught when running on a clean Pixel 6
emulator:

1. MainActivity extended FlutterActivity, but the audio_service
   plugin requires AudioServiceActivity. AudioService.init() threw
   PlatformException("The Activity class declared in your
   AndroidManifest.xml is wrong"), and the partial-engine-reinit that
   followed cascaded into a flutter_cache_manager SQLite EXCLUSIVE
   lock crash. Both errors clear with the correct base class.

2. The main manifest was missing two production permissions:
   - POST_NOTIFICATIONS — Android 13+ runtime requirement for the
     media notification (now-playing tile in the system tray).
     audio_service auto-prompts at init() time once declared.
   - ACCESS_NETWORK_STATE — used by ConnectionErrorBanner +
     flutter_cache_manager for connectivity awareness.

Note for future contributors: src/main/AndroidManifest.xml IS the
production manifest. The src/debug and src/profile variants only add
dev-only entries (e.g. INTERNET re-declared in debug for hot-reload).
There is no separate "release" manifest in Flutter's convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:33:30 -04:00
bvandeusen d5c8d316c5 fix(flutter): clear analyze --fatal-infos errors from admin parity slice
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>
2026-05-08 22:02:14 -04:00
bvandeusen 9f6ceb1731 feat(flutter/admin): users screen with users + invites sections
Single-scroll layout with two _SectionHeader-anchored blocks. Users
rows tap into AdminUserEditSheet. Invites row shows token (monospace)
with copy button, optional note, and a redeemed badge if applicable.

"Generate invite" opens a small dialog with an optional note field
(server hardcodes 24h TTL — admins can't customise expiry). On
success the new token is shown in a copy-once result dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:53 -04:00
bvandeusen afcaf56b9c feat(flutter/admin): user row + edit sheet (toggles, reset, delete)
AdminUserRow shows username + admin/auto-approve badges; tap opens
AdminUserEditSheet bottom sheet.

Edit sheet: SwitchListTile pair for is_admin and auto_approve_requests
(both surface server errors via SnackBar, including the last-admin
guard). Reset password takes a typed-input dialog (>=8 chars enforced
client-side; admin supplies the new password). Delete routes through
TypedConfirmSheet ("DELETE") and pops the sheet on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:10 -04:00
bvandeusen 7d207e56f5 feat(flutter/admin): users + invites mutations
Extends AdminUsersController (build-only since slice 2) with setAdmin,
setAutoApprove, delete, resetPassword. is_admin/auto_approve toggles
optimistically swap the row and roll back on server error (including
the last-admin guard 4xx). resetPassword is admin-supplied; no
auto-generation mode exists server-side.

Adds AdminInvitesController + adminInvitesProvider with create
(prepends new invite to the list, returns it for the copy-once
dialog) and revoke (optimistic remove + rollback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:56:34 -04:00
bvandeusen 3e905db906 feat(flutter/admin): quarantine screen + typed-confirm sheet
ExpansionTile per aggregated quarantine row — collapsed shows track,
artist · album, and "{N} reports: {top reason}". Expanded reveals the
nested per-user reports with their reasons + optional notes.

Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both
deletes route through TypedConfirmSheet which requires the user to
type "DELETE" before the action button enables.

Adds AdminQuarantineController with optimistic-removal + rollback,
mirroring AdminRequestsController's pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:56:00 -04:00