Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusen ab62a3d118 fix(flutter): cancel stale background fills when queue changes
The lazy source-build commit (1ddde12) introduced a race: when the
user taps play on a new track while a previous queue's
_fillRemainingSources is still working, the stale fill keeps
calling _player.addAudioSource() on the new player state — appending
old-playlist tracks into the new queue and confusing the player into
the "locked to one song" symptom.

Fix: queue-generation counter. setQueueFromTracks bumps
_queueGeneration first thing; the background fill captures its gen
at start and aborts before any further player mutation if a newer
queue has taken over. The previous play() never gets to mutate the
new player state.

Also resets _suppressIndexUpdates at the start of every
setQueueFromTracks (defensive — covers the case where a prior
backward-fill bailed on its gen check before reaching `finally`)
and only releases the flag in finally if we're still the active
gen.

Symptoms this should resolve:
- "locked to one song" after rapid play taps
- Late `play() returned 73189ms` lines indicating a previous
  hung play() call finally resolving and stepping on current state
- Player stuck in odd processingState after queue swaps
2026-05-11 22:31:45 -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 1ddde12959 perf: lazy player source build + Cache-Control on byte endpoints
Two unrelated wins as a single batch.

Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.

New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.

Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.

Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
  change rarely (re-scan, MBID enrichment); a day of cache is safe
  and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
  recompute when contents change; short enough for edits to feel
  fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
  are immutable for a given id (scanner re-indexes by file_path; new
  files get new ids). LockCachingAudioSource on the Flutter side
  already disk-caches, but proper headers let it skip even the
  conditional 304 on repeat plays.
2026-05-11 22:18:58 -04:00
bvandeusen 261b44522d fix(flutter): bump album-grid cellH slack — silence 1px overflow warnings
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.
2026-05-11 20:27:54 -04:00
bvandeusen 6f20a75f9b feat(flutter): playlist cover art via deterministic /api/playlists/{id}/cover
Same fix shape as albums: drift's CachedPlaylistAdapter.toRef was
returning coverUrl: '' because the cache table doesn't persist the
server-derived URL. Set it to /api/playlists/<id>/cover in the
adapter — handleGetPlaylistCover serves the cached collage from
disk, so the URL is deterministic and the round-trip through drift
no longer drops it.

PlaylistCard + PlaylistsListScreen pass the existing queue_music
icon as ServerImage's fallback, so when the server hasn't built a
collage yet (system playlists with no tracks at build time), the
endpoint 404s and the icon shows over the slate background instead
of an empty box.
2026-05-11 20:15:38 -04:00
bvandeusen 2d5f0691c2 diag(flutter): surface player errors + state transitions
Tap→audio measured at ~370ms — that path's fast. Real symptom: a few
seconds of audio, then silence with no event surfaced to Flutter.
ExoPlayer is failing/completing somewhere and we have no log to act
on.

Three changes, all log-only:
- Add onError to playbackEventStream so stream failures (404, range-
  request bugs, decoder errors, network drops) print instead of
  silently halting playback.
- Subscribe to playerStateStream and log playing + processingState
  on every transition. Silent stops will now show as a state shift
  to completed / idle / buffering with no resumption.
- Subscribe to processingStateStream separately to catch fine-grained
  state transitions ExoPlayer reports between source advances.

After hot-restart, tap-then-go-quiet should produce a sequence we
can read — most likely either "processingState=completed" partway
through (server returning premature EOS or wrong Content-Length) or
a thrown error from ExoPlayer's source-loading path.
2026-05-11 19:43:37 -04:00
bvandeusen e8a515dac4 fix(flutter): import debugPrint in player_provider for the timing logs 2026-05-11 19:35:12 -04:00
bvandeusen acc7149537 diag(flutter): instrument playTracks + cache appdir; fix CI
CI fixes:
- artist_detail_screen.dart: drop unnecessary foundation import (debugPrint
  comes from material) and unused metadata_prefetcher import.

Playback timing visibility (so we can stop guessing where the lag
lives):
- playTracks now logs serverUrl / token / configure / setQueue /
  play() returned, each stage in milliseconds. The next time you
  tap play, we'll see exactly where the seconds go.
- setQueueFromTracks adds two more measurements: total source-build
  time across all tracks, and setAudioSources duration.

Small concrete win:
- audio_handler caches the application cache dir path on first use
  (already cached in _maybeRegisterStreamCache; now also used in
  _buildAudioSource for the LockCachingAudioSource path). One less
  platform channel hit per track on cache-miss queue builds.

Once we see real numbers we can decide whether the fix is to build
sources lazily (initial source first → play → background-add the
rest), pre-warm the audio handler at app start so playTracks skips
serverUrl + token reads entirely, or something else.
2026-05-11 19:11:44 -04:00
bvandeusen 4ede37d9ad fix(flutter): stop the cache feedback loop — playback no longer competes
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.
2026-05-11 19:02:16 -04:00
bvandeusen 4bd069430b feat(flutter): extend metadata prefetch to library tabs + artist detail
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.
2026-05-11 18:52:37 -04:00
bvandeusen 22152b1ba3 feat(flutter): metadata prefetcher — pre-warm drift for likely tap targets
Across-the-board sluggishness was every cold-cache tap doing one
network round trip while the user waits. SWR helps on re-visits but
the first time you tap a tile from home you eat the latency.

MetadataPrefetcher listens to homeProvider. When /api/home returns,
it fires fire-and-forget reads on albumProvider + artistProvider for
the top-N items in each home section (recently added, rediscover,
most played, last played). Each provider read triggers the existing
cold-cache path, which writes to drift. By the time the user
actually taps a tile, it's already a drift hit and the detail
screen renders instantly.

Cap N=8 per section (covers what's visible on a typical phone
without scrolling). Set spans dedupe across sections so popular
artists don't get fetched five times. Errors are swallowed — a
failed prefetch is silent and the tile falls back to its on-tap
fetch behavior.

Wired alongside the existing audio prefetcher in app.dart's
postFrameCallback.
2026-05-11 18:44:09 -04:00
bvandeusen 572325e23f fix(flutter): write cachedTracks rows from playlist fetch (was the empty-rows bug)
Detail screen showed empty rows for system playlists because the
fetch batch wrote cachedPlaylists, cachedPlaylistTracks (positions),
cachedArtists, and cachedAlbums — but never cachedTracks themselves.
The detail screen's LEFT OUTER JOIN cachedTracks then returned null
on every row, so trackId was null and titles came back empty.

PlaylistTrack on the wire carries enough to populate cachedTracks
(id, title, albumId, artistId, durationSec). Adds a third dedup map
in fetchAndPopulate, batched with the existing artist + album writes.
track_number / disc_number aren't on the wire so they default to 0;
the detail screen doesn't surface them.

Reusing cachedTracks across albumProvider + playlistDetailProvider
also means tapping a playlist's track to play it now finds the row
in drift instead of triggering another fetch.
2026-05-11 18:38:21 -04:00
bvandeusen 8d466ebdd5 fix(flutter): reconcile stale playlists + recover from 404 on tap
The 404 on tapping the For-You tile traced to stale drift rows.
BuildSystemPlaylists rotates UUIDs on every rebuild, so old For-You
/ Songs-Like ids accumulate in cachedPlaylists. The list provider's
fetchAndPopulate was only doing insertOrReplace, which adds new rows
but never removes the obsolete ones — so the home tile renders 8
playlists when the server only knows 4, and 4 of those tiles 404 on
tap.

Two fixes:

playlistsListProvider.fetchAndPopulate now reconciles. After
fetching the fresh list, deleteWhere any user-owned drift row whose
id isn't in the fresh response, then upsert the fresh set in the
same batch. Public-from-others rows are left alone — they're not
keyed by ownership and we don't want to drop someone else's public
playlist just because the current user's response didn't enumerate
it. Operates inside the existing batch so it's atomic.

playlistDetailProvider.fetchAndPopulate now treats a DioException
404 as "this row is stale": delete the cachedPlaylists + any
cachedPlaylistTracks rows for this id, return false so the UI yields
emptyDetail. The next render of the home row sees the row gone and
the tile disappears, completing the cleanup.

Side note: every system-playlist rebuild discards drift rows for the
just-evicted UUIDs and writes the new ones. That cycle's been
silently churning since system playlists shipped — this is the
first time the cleanup actually runs.
2026-05-11 18:32:40 -04:00
bvandeusen af5744f8ab perf(home): aggregate-first rewrites for two scan-the-world queries
handleGetHome itself is well-architected (5 sections in parallel via
goroutines, latency-bound by the slowest single query). The cold-
start lag is two of those queries doing wider scans than necessary.

ListLastPlayedArtistsForUser was iterating FROM artists a with a
LATERAL play_events join per row — O(total_artists in library) plan
even for users who've only played a handful. Inverted: aggregate the
user's plays by artist_id first via the play_events → tracks join
(uses play_events_user_track_idx + tracks pkey), then attach the
artist row and lateral cover/count subqueries only for the artists
that actually appear. Cost now bounded by play history, not library
size.

ListMostPlayedTracksForUser was joining tracks/albums/artists for
every play_event row before grouping — O(total plays) work for
joins. Pre-aggregated play_events into a CTE keyed by track_id +
count(*), then joined to tracks/albums/artists only for the
distinct-tracks survivors. Order-by uses the pre-computed count.

No handler or generated-Go signature changes — both queries return
the same rowset shape, just much faster on libraries where total
artists/plays >> distinct-played-artists/distinct-played-tracks.
2026-05-11 18:26:20 -04:00
bvandeusen c7549bbe48 perf(api): collapse N+1 in /api/artists/{id} + 1 round-trip in /api/albums/{id}
Two new sqlc queries replace three sequential per-album round trips
that were dominating detail-screen latency.

GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then
GetArtistByID — separate round trips for one logical lookup. The new
query joins albums + artists with sqlc.embed and returns both in one
SELECT. Detail-page DB cost: 3 trips → 2.

ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the
artist's album list, then issuing one CountTracksByAlbum per album to
populate track_count. On a 30-album artist that's 32 sequential
queries — each ~5ms over a local DB, ~30ms over a remote one. The
new query embeds the album row + a correlated count(*) subquery, so
every album's track count comes back in one SELECT regardless of
album count. Detail-page DB cost: 1 + N → 1 + 1.

Together these account for the bulk of cold-cache navigation latency
on the Flutter client. Combined with the existing SWR + nav
hydration on the client side, detail screens should render their
header instantly and the body within one round trip instead of
N+constant.
2026-05-11 18:13:27 -04:00
bvandeusen 9cac664679 feat(flutter): register stream-cached files in the audio cache index
Closes the gap where LockCachingAudioSource wrote files to disk but
never told AudioCacheManager about them — meaning evict() couldn't
reclaim stream-cached files when usage exceeded the cap, only
explicitly-pinned downloads.

Wire just_audio's bufferedPositionStream as the "download complete"
signal: when bufferedPosition reaches duration (with 200ms slack for
header bytes), look up the on-disk file at the LockCaching path,
read its size, and insert an audio_cache_index row via the new
AudioCacheManager.registerStreamCache(). Source defaults to
incidental so stream-cached tracks are first to be evicted under
pressure.

Dedupe via _streamCacheRegistered Set so we don't hit drift on every
~200ms buffered-position emit. Cache the application cache dir path
on first use for the same reason.

Eviction now sees the full set of files on disk; usageBytes() (which
already walks the dir) and evict() (which reads the index) are
finally consistent for stream-cached tracks. Pinned tracks keep
their existing manual-download flow unchanged.
2026-05-11 17:46:32 -04:00
bvandeusen c08f4ace80 fix(flutter): cache usage display reflects actual disk, not just index
The Storage card has been showing "0 B" for users who only stream
(never explicitly pin or download an album). usageBytes() summed
SUM(size_bytes) from audio_cache_index — but the streaming path
through audio_handler writes files via LockCachingAudioSource without
ever inserting an index row, so the index undercounts (often to
zero) for normal use.

Walk the cache directory instead. Catches everything on disk:
manually pinned tracks (registered in the index), stream-cached
tracks (LockCaching), partial downloads. Falls back gracefully when
a file is racing against concurrent writes / deletes.

Eviction still operates on the index (it needs the source/recency
metadata to pick eviction order). Stream-cached files aren't subject
to eviction today — separate problem; addressed when we wire a
download-complete hook from LockCaching back into the index.
2026-05-11 17:41:07 -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 a7f35a5d6d feat(flutter): full player — combined action row above seek
Move shuffle / repeat / queue out from below the play controls and
combine with the like + kebab into a single row sitting just above
the seek bar. Title row drops back to title-only (truly centered now,
no Stack needed since nothing competes for the row's right edge).

Layout order is now: art → title → artist → album → [shuffle, repeat,
queue, like, kebab] → seek → prev/play/next.

The "queue" icon in the top-right of the AppBar stays for now —
redundant with the new row but matches what users have already
muscle-memoried for opening the queue from any player state.
2026-05-11 17:25:27 -04:00
bvandeusen 8c00ee21c7 feat(flutter): infinite scroll on library Artists + Albums tabs
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.
2026-05-11 15:41:01 -04:00
bvandeusen 2500914498 fix(flutter): persistent release signing key via CI secret
Update installs were failing at the system scanner step because every
release APK was signed with a different signature: build.gradle.kts
fell back to signingConfigs.getByName("debug"), and the debug
keystore is generated per-machine — so every CI runner had a fresh
random key. Android refuses to upgrade an installed app when the
signature doesn't match, hence the "App not installed" failure
after the scan.

Two-part fix:

1. build.gradle.kts now defines a real "release" signing config when
   ANDROID_KEYSTORE_PATH is exported (with the matching password +
   alias env vars). Without those, falls through to the debug
   keystore so local `flutter run --release` still works.

2. flutter.yml decodes ANDROID_KEYSTORE_B64 (CI secret, base64 of the
   real release keystore) into a tmp path before the release-APK
   build step, then exports ANDROID_KEYSTORE_PATH + the three
   password/alias secrets as env vars. CI now hard-errors if the
   keystore secret is missing on a tagged build — we don't want
   another silent debug-keystore release.

OPERATOR ACTION REQUIRED before the next tag:

1. Generate a release keystore (one-time):
     keytool -genkey -v -keystore minstrel-release.keystore \
       -alias minstrel -keyalg RSA -keysize 2048 -validity 10000

2. Base64 it:
     base64 -w0 minstrel-release.keystore > keystore.b64

3. In Forgejo, add four repo secrets:
     ANDROID_KEYSTORE_B64    = contents of keystore.b64
     ANDROID_KEY_ALIAS       = minstrel
     ANDROID_STORE_PASSWORD  = the password you set
     ANDROID_KEY_PASSWORD    = same password (or different alias pwd)

4. Keep minstrel-release.keystore offline and backed up — losing it
   means you can never sign an upgrade for any existing install.

5. Existing installs from prior debug-keyed releases must be
   uninstalled before installing the first key-signed release. This
   is a one-time transition; future tagged builds will upgrade
   cleanly.
2026-05-11 14:22:32 -04:00
bvandeusen 0134281b8c fix(flutter): CI analyze + center title in full player
- artist_detail_screen.dart missed an `import '../models/artist.dart'`
  for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
  parameters in <...> which lint reads as HTML. Switched to backticks.

Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
2026-05-11 13:01:52 -04:00
bvandeusen 4dbb3190ff fix(flutter): player updates on track change + kebab artist nav + Start radio
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.
2026-05-11 12:32:43 -04:00
bvandeusen 2299824ad9 fix(flutter): system playlist tap loads tracks (mirror album refetch fix)
System playlists now show in the home row (good!) but tapping them
landed on an empty playlist. Same root cause as the earlier album
bug: playlistsListProvider wrote the playlist *row* to drift but
never the tracks. playlistDetailProvider only triggered cold-fetch
when the row was missing, so it yielded with an empty track list.

Refactor playlistDetailProvider to mirror albumProvider's approach:
- fetchAttempted guard (one-shot per subscription).
- Detect "playlist row exists but trackRows empty" and trigger the
  same fetchAndPopulate the cold-cache path uses. Drift watch
  re-emits with populated tracks.
- 10s timeout on the API fetch + diagnostic prints so any failure
  surfaces in logs.

While here, fix two adjacent issues:
- Wipe + re-insert this playlist's track positions on every fetch
  so server-side deletions actually propagate. Without the wipe,
  removed tracks would linger in drift forever.
- Write the artist + album rows referenced by the fetched tracks
  into cachedArtists / cachedAlbums (deduped). Without this, the
  joined artistName/albumTitle columns in the playlist track rows
  surface empty.

Cover art for system playlists is a separate issue — server emits
coverUrl but it may be empty for system mixes; PlaylistCard falls
back to the queue_music icon. Will tackle in a follow-up.
2026-05-11 12:26:17 -04:00
bvandeusen a77d4ceac0 fix(flutter): library Artists tab 404 + Albums tab 3-up grid
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.
2026-05-11 12:21:24 -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 f4d07ef9a1 diag(flutter): trace playlist provider — wire vs drift vs filter
Web UI shows system playlists; Flutter shows placeholders. Wire shape,
adapters, drift schema, and filter constants all line up on inspection,
so adding instrumentation to pinpoint where the system rows fall out:

- Log what /api/playlists?kind=all actually returns (owned/public
  counts, including how many of owned are system).
- Log what cacheFirst sees on each drift emit: total rows, filtered,
  how many are system, how many landed in owned vs pub, plus the
  user.id used for the owned-vs-pub split.

Also add the 3s connectivity timeout that albumProvider/artistProvider
got — keeps the alwaysRefresh path from blocking on a stalled
connectivity stream.

Three log lines from one home-screen visit will tell us:
- Does the server emit system rows? (wire log: system=N)
- Do they land in drift? (drift log: filtered system=N)
- Do they survive the user-id filter? (drift log: owned system=N)
2026-05-11 11:07:24 -04:00
bvandeusen 74cc76b369 fix(flutter): preserve dev-version safety net + update version tests
Two test failures from the comparator rewrite:

1. "different unparseable strings → newer" was useful — branch-name
   builds (e.g. PackageInfo reports 'main' while server reports
   'dev') should still surface the banner so the operator sees that
   something is misaligned. Restore that fallback: if both sides
   parse to all-zero components (no numeric structure on either),
   compare as strings.

2. "honors prerelease ordering" tested pub_semver behavior we no
   longer use. Replaced with tests that cover what the new
   comparator actually does: zero-padding for length mismatch,
   leading-zero normalization, 4-part date+build comparisons, and
   month-rollover correctness without leading zeros.
2026-05-11 10:48:06 -04:00
bvandeusen a65474284a fix(flutter): satisfy curly_braces_in_flow_control_structures lint 2026-05-11 10:36:40 -04:00
bvandeusen 2f67c26a45 fix(flutter): pubspec version must be 3-part semver
flutter pub get rejects "2026.05.11.0+1" — it requires strict
MAJOR.MINOR.PATCH+BUILD with no leading zeros. Use 2026.5.11+1
as the local default; CI's --build-name="${TAG#v}" still injects
the full 4-part tag for release builds, so production APKs report
the tag verbatim while local dev builds get a sensible recent value.
2026-05-11 10:31:14 -04:00
bvandeusen ab8a86e794 fix(flutter): update banner false positive + lock-screen control routing
Update banner showing on identical versions:
- pubspec.yaml was stuck at the placeholder 0.1.0+1, so
  PackageInfo.version returned "0.1.0" while the server reported the
  actual release tag (e.g. "2026.05.10.1"). Comparison correctly said
  "newer" → banner always showed.
- Bump pubspec to 2026.05.11.0+1 so the local default matches the
  release cadence even before CI overrides it.
- Update flutter.yml release step to pass --build-name="${TAG#v}" so
  every tagged APK reports the tag as its PackageInfo.version. Future
  releases stop drifting from pubspec.
- Rewrite isVersionNewer to do component-wise int comparison with
  zero-padding: pub_semver.Version.parse rejects 4-part date versions
  like "2026.05.10.1", at which point the old code fell back to
  string inequality and treated "2026.05.10" as newer than itself
  vs "2026.05.10.0". Drop the pub_semver import (no longer used).

Lock-screen play/pause not responding:
- PlaybackState only listed MediaAction.seek in systemActions, which
  on Android 13+ means tapping the lock-screen play/pause button
  doesn't route back to the AudioHandler. Add play, pause,
  skipToNext, skipToPrevious to the set.
- Add androidCompactActionIndices: [0, 1, 2] so the compact
  notification view explicitly maps the three buttons.

Album art being smaller than the lock-screen frame is upstream of
this commit — the cover-cache writes whatever pixel dimensions the
server returns. If the server's /api/albums/<id>/cover returns small
thumbnails for these albums, the lock screen renders them at that
size. Worth a separate look at the server cover-emit path.
2026-05-11 10:27:04 -04:00
bvandeusen 727a0760da fix(flutter): refetch album when row exists but tracks don't
artistAlbumsProvider populates cachedAlbums (album rows only) without
ever fetching their tracks. When the user taps from the artist grid
into an album, albumProvider sees a drift hit on the album row, never
triggers cold-cache, and yields with an empty track list — visible as
the album header rendering above an empty body.

Refactor albumProvider's cold-cache logic into a helper closure that
either branch (no album row OR album row + no tracks) can call. Add a
fetchAttempted guard so a server response that legitimately returns
zero tracks doesn't loop forever.

Decision flow:
- albumRows empty: cold-fetch (existing path), yield empty on
  failure/offline
- albumRows hit but trackRows empty AND not yet attempted: same
  cold-fetch — drift watch re-emits with the populated tracks
- albumRows hit, trackRows hit (or attempted already): yield as-is
2026-05-11 08:52:45 -04:00
bvandeusen 983a9d92be fix(flutter): tighter track row + missing artist in mini player + hide redundant artist on artist page
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
  so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
  numbers cramp.

Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
  track in album detail; layout change wasn't the cause. Real cause:
  albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
  never cachedArtists. The drift JOINs that build the AlbumRef + each
  TrackRef returned null for the artist row, so artistName fell back
  to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
  off the album header + each track, inserts them into cachedArtists
  alongside the album/tracks. Subsequent JOINs populate artistName,
  the audio handler stamps it on MediaItem, and the mini player picks
  it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
  re-fetched (cache invalidation TBD). Future cold-cache hits will.

Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
  detail grid since the page header already names the artist. cellH
  trimmed by the 16dp artist line.
2026-05-11 08:47:50 -04:00
bvandeusen 21ab0d78bb fix(flutter): artist albums grid — 3 per row, title wraps, no overflow
AlbumCard gains optional `width` (default 140 keeps horizontal lists
unchanged) and `titleMaxLines` (default 1) so callers can let the
title wrap to a second line. Cover sizes to width - 16 instead of a
hardcoded 124, so the card scales down cleanly when a narrower cell
width is passed in.

Artist detail grid: switch to 3 columns. LayoutBuilder computes the
cell width from the available width so AlbumCard sizes to the cell
exactly (no overflow). cellH = cover + gap + 2-line title + artist
line + small fudge — tight enough that there's no visible gap below
the card, tall enough to fit a wrapped title.
2026-05-11 08:43:58 -04:00
bvandeusen 107abda97e fix(flutter): album/artist covers in artist detail + tighter grid
Drift cache intentionally drops cover_url (server-derived). The
adapters comment says "REST cold-cache fallback briefly shows the real
values before drift takes over" — but once drift takes over, covers
are empty forever. Fix per source:

- Album cover: server constructs the URL deterministically as
  /api/albums/<id>/cover (internal/api/convert.go:69). Mirror that
  in CachedAlbumAdapter.toRef so AlbumRef.coverUrl is non-empty
  whether the row came from a fresh fetch or a drift hit. Restores
  cover art on the artist detail album grid (and any other surface
  reading albums from drift).

- Artist cover: server picks a representative album and reuses its
  cover (convert.go:98). Drift doesn't store the pointer, so derive
  client-side via the artist's first loaded album. New _ArtistAvatar
  prefers a non-empty server-emitted coverUrl and falls back to
  /api/albums/<firstAlbumId>/cover, then slate while the album list
  is still loading.

Album grid spacing was off because childAspectRatio: 0.8 inflated
each cell taller than the AlbumCard's actual ~160dp footprint,
leaving a visible gap below every card. Switch to mainAxisExtent: 168
with explicit 8dp main/cross spacing — cells now match the card and
sit on a clean grid.
2026-05-11 08:39:41 -04:00
bvandeusen 2b033131e0 fix(flutter): mini player heart/kebab span title+artist height
Move the like + kebab buttons out of the title row and place them as
siblings to the title/artist column. Row's default crossAxisAlignment
centers them against the row's full 48dp height (set by the album
art), so visually they sit at the vertical center of the title+artist
block instead of being pinned to the title baseline.

Width stays 32dp each — horizontal footprint matches what we had.
Bumped icon size 18→20 since they have more vertical space to occupy.
2026-05-11 08:36:39 -04:00
bvandeusen f88e82a777 fix(flutter): album/artist detail covers + cacheFirst tracing for hangs
Album detail screen rendered the cover slot as a bare slate Container
— no actual image. Wire ServerImage at /api/albums/<id>/cover (96dp,
rounded). Same for artist detail: ClipOval around ServerImage of the
artist's coverUrl, falling back to slate when empty.

Artist navigation hang: artistProvider goes through cacheFirst, which
had no logging — so we couldn't see whether the cold-cache fetch was
running. Add an optional `tag` parameter that, when set, debugPrints
each step (drift hit/miss, connectivity, fetch start/done/error).
Wire tags into artistProvider and artistAlbumsProvider, plus add the
3s connectivity timeout there too as belt-and-suspenders.

Next test pass should show cacheFirst[artist(<id>)]: lines that
pinpoint where the artist screen is sticking.
2026-05-11 08:35:02 -04:00
bvandeusen 3c0806d8fd diag(flutter): time-box connectivity check + trace album cold-cache path
Two changes to surface where detail-screen spinners hang:

1. Connectivity().checkConnectivity() goes over a platform channel
   that on some Android builds stalls. Wrap with a 2s timeout and
   default to "online" if it times out — being wrong about
   connectivity costs at most one failed fetch, but being stuck
   spins the UI forever.

2. albumProvider's cold-cache flow now debugPrints at every step:
   drift miss → connectivity result → API call → drift write → ack
   the re-emit. Also adds a 10s timeout on the API fetch and a 3s
   timeout on the connectivity await so a hang surfaces as an
   error-yielded empty AlbumRef instead of an infinite spinner.

Once the operator's next test produces logs we can pinpoint which
step is hanging. The diagnostic prints stay until the issue's root
cause is confirmed; will be removed in a follow-up.
2026-05-11 08:28:32 -04:00
bvandeusen 4621846ec4 fix(flutter): seed connectivityProvider with initial value (unhang detail screens)
Root cause for "only the first tile loads, others spin forever":

Connectivity().onConnectivityChanged only emits on connectivity
*changes*, not on subscription. Cold-cache code paths in albumProvider,
artistProvider, playlistsProvider, likes etc. all gate their fetches
on `await ref.read(connectivityProvider.future)`. Until the OS
happened to report the first connectivity flip, that future never
resolved — so any detail screen for an album/artist/playlist not
already in the drift cache hung indefinitely at the connectivity
check.

The "first tile" appeared to work because by the time the user
tapped it, a connectivity event had landed (or that album was already
cached from a prior session). Subsequent tiles whose data wasn't yet
cached blocked.

Switch the provider to async* and seed it with an immediate
checkConnectivity() before forwarding the change stream. .future
now resolves on first read regardless of whether the OS has reported
a change yet.
2026-05-11 08:21:55 -04:00
bvandeusen d4d936ee57 fix(flutter): mini player art fills its 48dp box (BoxFit.cover)
Image with width+height but no fit paints the source at its intrinsic
resolution inside the box. The cover-cache thumbnails are smaller
than 48dp, so the art rendered as a tiny inset in the slate box
instead of filling it. Cover stretches/crops uniformly to fill.
2026-05-11 08:17:17 -04:00
bvandeusen b6b73fdd0c fix(flutter): live seek bar (~200ms) + breathing room above controls
Why the seek bar appeared frozen: it was reading
PlaybackState.updatePosition, which audio_service only updates on
event transitions (play/pause/buffer/seek). Between events it sits
unchanged, so the bar only jumped at intervals.

Expose just_audio's positionStream (~200ms cadence) from the audio
handler, wrap as positionProvider, and read that in both the mini bar
and the full player. Now the bar advances continuously while playing.

While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
2026-05-11 08:15:58 -04:00
bvandeusen 2a5b6970e9 feat(flutter): show album name beneath artist in full player
Title (22pt parchment) → artist (14pt ash) → album (12pt ash). Album
is conditional — drops out if MediaItem.album is empty rather than
leaving a blank line. Tightened the gap to the seek bar (24→20) to
absorb the new line on small screens.
2026-05-11 08:12:29 -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 3162fedd3b fix(flutter): card taps via Material+InkWell, not GestureDetector
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.

Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:

- InkWell registers via the Material ripple system instead of the
  raw gesture arena, sidestepping any subtle deferToChild edge cases
  inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
  visually — if the ripple shows but nothing navigates, the issue is
  on the route side; if no ripple, the tap never landed.

Material color is transparent so the existing dark page background
shows through unchanged.
2026-05-11 07:57:10 -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 a3985f1138 fix(flutter): cover images — wait for token, file:// loader, graceful errors
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.
2026-05-11 07:44:28 -04:00
bvandeusen 7cabe4efef fix(flutter): player bar — title visible, controls stacked, no overflow
Three issues from the screenshot:

1. Title invisible: LikeButton + TrackActionsButton were raw IconButtons
   with the default 48dp tap target, eating ~96dp of the title row.
   Wrap both in 32×32 SizedBox at the call site so the title actually
   gets enough room to render (still ellipsizes if needed).

2. Layout per operator's revised spec: track info gets a 2:1 share of
   the row vs the controls column. The controls column now stacks the
   shuffle/repeat/queue sub-row above the prev/play/next sub-row, both
   right-aligned.

3. Right-edge 2px overflow: previous play controls were 40+48+40=128dp
   in a flex-1 slot of ~110dp. Resize to 32+40+32=104dp matching the
   shuffle/repeat/queue row above (3×32=96dp), with the play button
   slightly larger to keep visual hierarchy.

Drop the volume provider import since the slider is gone (handled by
phone hardware buttons).
2026-05-11 07:34:11 -04:00
bvandeusen d703fc27f8 fix(flutter): album/artist card tap (HitTestBehavior.opaque)
Same root cause as the playlist card fix in f65650f — GestureDetector
defers to children by default, so taps over the cover image area
(ServerImage / SVG fallback) didn't reliably propagate. Mark the
detector opaque so the entire card surface is tappable, not just the
text below the cover.
2026-05-11 07:30:05 -04:00
bvandeusen f65650fb6d fix(flutter): player bar layout, system playlist tap, banner height
Player bar: drop the volume slider — phone hardware controls volume.
Right cluster collapses to a single row of shuffle/repeat/queue.

Playlist card: GestureDetector defers to children by default, so taps
over the cover image area didn't always register. Add HitTestBehavior.opaque
so the whole card surface is tappable.

Update banner: was 3-4× the height of its text — IconButton's default
48dp tap target plus generous vertical padding dominated. Tighten
padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim
the action button to 28dp height with shrinkWrap tap target.

Home screen: ClampingScrollPhysics + 140dp bottom padding (already
applied in working tree) so scroll stops just above the mini-player.
2026-05-11 07:27:14 -04:00
bvandeusenandClaude Opus 4.7 51afc992d4 fix(web): apply data-testid attrs to PlayerBar that were dropped from a8e6e1c
The previous commit message said I added data-testid="player-bar-compact"
+ "player-bar-desktop" to PlayerBar.svelte but the edits actually
dropped silently — only the test file changes landed. Without the
testids, the test file's within(getByTestId(...)) calls all fail.

This adds the actual attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:52 -04:00
bvandeusenandClaude Opus 4.7 a8e6e1c2f7 fix(web,flutter): PlayerBar test scoping + system-playlists revalidate
Two bundled fixes:

### Web: PlayerBar test failures
PlayerBar renders both compact (md:hidden) and desktop (hidden md:flex)
blocks; jsdom doesn't apply CSS media queries so both DOM trees are
present in tests. screen.getByRole/getByText found duplicates →
14 test failures.

Added data-testid="player-bar-compact" + "player-bar-desktop"; tests
scope queries via within(getByTestId(...)). Compact is the focus of
#358, so most tests scope there. The "Up next" subgroup explicitly
scopes to desktop since that copy was dropped from compact.

### Flutter: system playlists not loading
cacheFirst was too conservative — when drift had user-created
playlists from sync but no system playlists (For-You / Discover),
fetchAndPopulate never fired (drift wasn't empty). Result: home
tile row showed user playlists but never the system ones.

Added cacheFirst alwaysRefresh option = stale-while-revalidate.
playlistsListProvider opts in: yields cache immediately, then kicks
off REST refresh in background. Drift watch() picks up the new rows
and re-emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:22 -04:00
bvandeusenandClaude Opus 4.7 19061cd10c feat(flutter): full parity for player bar — shuffle/repeat/volume + Like + kebab + 3-column layout (#356)
Mirrors web's compact PlayerBar both visually (3-column top + full-
width seek bottom) and functionally — closes parity gaps that had
been hidden because the prior mini-bar surfaced none of these
controls.

### audio_handler.dart

- Override `setShuffleMode` → delegates to just_audio's
  `setShuffleModeEnabled`.
- Override `setRepeatMode` → maps audio_service modes (none/one/all/
  group) to just_audio's LoopMode (off/one/all).
- New `setVolume(double)` + `volumeStream` getter (audio_service's
  PlaybackState doesn't carry volume — surface it separately).
- `_broadcastState` now populates PlaybackState's shuffleMode +
  repeatMode fields, and re-fires on shuffle/repeat/loop stream
  changes so UI subscribers stay in sync.

### player_provider.dart

- New `volumeProvider` (StreamProvider<double>).
- `PlayerActions` gains `toggleShuffle()`, `cycleRepeat()` (none →
  all → one → none), and `setVolume(double)`.

### player_bar.dart

Complete rebuild matching the operator-designed layout:

  ┌──────────┬───────────┬──────────────────┐
  │ art      │ ♥   ⋮     │ 🔀 🔁 ☰           │  ← short row
  │ title    │           │                  │
  │ artist   │ ⏮  ⏯  ⏭   │ volume slider    │  ← play row, full size
  ├──────────┴───────────┴──────────────────┤
  │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━ 3:21  │
  └─────────────────────────────────────────┘

- Column 1 (35%): cover + title + artist. Tap → /now-playing.
- Column 2 (intrinsic): like + kebab on top, play controls (40/48/40)
  on bottom.
- Column 3 (30%): shuffle/repeat/queue on top, volume slider on bottom.
- Bottom: full-width seek slider with mm:ss labels.

`TrackActionsButton` reused for the kebab; reconstructs a minimal
TrackRef from MediaItem extras (same pattern NowPlayingScreen
already uses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:59:53 -04:00
bvandeusenandClaude Opus 4.7 b2d1a9ea10 fix(web): compact PlayerBar — 3-column top row, full-width seek bottom (#358)
Replaces the kebab-with-popover PlayerOverflowMenu approach with all
player controls visible inline. Operator's stated intent was "two-row
layout with all controls visible," not "kebab hides shuffle/repeat/
queue/volume" — restructured to match.

### Compact view (below md)

  ┌─────────┬───────────┬───────────────────┐
  │ art     │ ♥   ⋮     │ 🔀 🔁 ☰            │  ← short sub-row
  │ title   │           │                   │
  │ artist  │ ⏮  ⏯  ⏭   │ volume slider     │  ← play row at full size
  ├─────────┴───────────┴───────────────────┤
  │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
  └─────────────────────────────────────────┘

- Column 1: cover + title/artist
- Column 2: like+kebab on top sub-row, play controls (40/48/40) below
- Column 3: shuffle/repeat/queue on top, volume slider below
- Bottom: full-width seek slider with time labels

### Desktop view (md+)

Existing 3-column layout preserved; only change is the redundant
PlayerOverflowMenu kebab (which had been mounting at all widths)
is no longer there. Volume + shuffle + repeat + queue stay inline
in the right cluster as before.

### Cleanup

- Deleted PlayerOverflowMenu.svelte + its test (no callers remain)
- The TrackMenu kebab (per-track actions) stays — it's a varying
  list of actions that doesn't fit inline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:42 -04:00
bvandeusen c3ecd1bec8 fix: gofmt -s on version.go (tab-indent doc-comment code block) 2026-05-10 23:15:29 -04:00
bvandeusenandClaude Opus 4.7 4c4399c9bb feat(server,web): expose server version via /healthz + display in Settings
Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:

### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
  overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
  "status" + "min_client_version". Backward-compat: existing clients
  ignore unknown JSON fields. Endpoint stays unauthenticated.

### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
  go build -ldflags so the binary's ServerVersion is stamped at
  link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
  output (the git tag for tag pushes, "main" for branch pushes);
  build step passes it as `--build-arg MINSTREL_VERSION=...`.

### Web
- web/src/lib/components/ServerVersion.svelte: small understated
  text ("Server v2026.05.10.2") that fetches /healthz on mount.
  Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:13:19 -04:00
bvandeusenandClaude Opus 4.7 e16bdd9546 fix(web): read snake_case keys in MobileAppDownload (#397)
Server's clientVersionResponse marshals to {version, apk_url,
size_bytes} but the component was reading body.apkUrl / body.sizeBytes
(camelCase). The !body.apkUrl guard was always true → early return →
download button never rendered even when /api/client/version returned
200 with valid data.

Verified directly: /api/client/version returns
  {"version":"v2026.05.10.1","apk_url":"/api/client/apk","size_bytes":65301242}
on the deployed v2026.05.10.1 image; the UI just wasn't reading those
keys.

Map wire (snake_case) → component (camelCase) explicitly via a
WireInfo type so the boundary is auditable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:10:22 -04:00
bvandeusenandClaude Opus 4.7 5054c46385 fix(web): collapse the entire Mobile app section when no APK bundled
When /api/client/version returns 404 (no APK in /app/client/, e.g.
v2026.05.10.0 which had a failed CI APK-attach step), the previous
shape rendered the section heading + intro paragraph with an empty
hole where the button should be. Confusing — looks like a broken
button.

Moved the section wrapper, heading, and intro paragraph INSIDE
MobileAppDownload so the whole block collapses together when info
is null. Settings page just mounts <MobileAppDownload />; nothing
visible if no APK is available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:44:38 -04:00
bvandeusenandClaude Opus 4.7 e4578593d1 ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into a single CI_TOKEN secret
Both prior secrets did related work (one for registry push, one for
release asset I/O) and required separate Forgejo PATs to manage.
Replacing with a single CI_TOKEN keyed off a single PAT with the
union of scopes (write:repository + write:package).

- flutter.yml: APK attach step → CI_TOKEN
- release.yml: docker login → CI_TOKEN; APK fetch step → CI_TOKEN

No behavior change — same calls, single secret name. Operator needs
to set CI_TOKEN in repo settings (Actions → Secrets) with a PAT that
has write:repository + write:package; the prior REGISTRY_TOKEN /
RELEASE_TOKEN secrets can be removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:33:10 -04:00
bvandeusenandClaude Opus 4.7 154f415f92 fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:27 -04:00
bvandeusenandClaude Opus 4.7 2435ef7961 feat(web): MobileAppDownload component on login + settings (#397 follow-up)
Closes the discoverability gap on the in-app update flow — the
/api/client/apk endpoint exists but had no web UI surface to find
it. User asked to be able to "visit the site on my phone and
download the apk from there."

- web/src/lib/components/MobileAppDownload.svelte — fetches
  /api/client/version once on mount; renders a download link with
  version + size when 200; renders nothing on 404 (no APK bundled,
  graceful degradation in dev environments and pre-CI-wiring images).
- Mounted on the login page (below the Register link) so the link
  is discoverable without authentication. The /api/client/* endpoints
  are themselves unauthed, so the flow works end-to-end for any
  visitor on a phone.
- Also mounted in Settings → Mobile app section for logged-in users
  who want to grab the matching APK for sideloading on a different
  device.

Browser handles the download via the server's existing
Content-Disposition: attachment; filename="minstrel.apk" header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:23:56 -04:00
bvandeusenandClaude Opus 4.7 f6e27cd39a fix(flutter/test): isVersionNewer date-style assertion was wrong
pub_semver is permissive with leading zeros — '2026.05.10' parses as
2026.5.10, so date-style tags get strict semver ordering, not the
unparseable-fallback path. The test was asserting "any difference =
newer" for date strings, which is incorrect; the actual behavior
(and the right behavior) is proper ordering.

Split the test group into two:
- date-style (parses as semver): newer/older/equal assertions
- truly unparseable (e.g. branch names like 'main', 'dev'): the
  fallback's "any difference = newer" semantics still apply

Implementation in client_update_provider.dart unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:53 -04:00
bvandeusenandClaude Opus 4.7 f3378dd610 fix(flutter): apply Riverpod 3 NotifierProvider conversion missed in 14d7678
The previous fix-forward intended this change but the Edit didn't
make it into the commit (only the Go side landed). Re-applying the
StateProvider → NotifierProvider conversion so flutter analyze stops
failing on the undefined-function for StateProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:02:29 -04:00
bvandeusenandClaude Opus 4.7 14d7678624 fix: CI lint errors after #397 commits
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
  _dismissedVersionsProvider with a small Notifier<Set<String>>
  + NotifierProvider, mutating via an `add(version)` method.
  Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
  unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
  os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
  on test cleanup, no manual Unsetenv needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:01:43 -04:00
bvandeusenandClaude Opus 4.7 e57a53a92e ci(server): bundle Android APK into image on tag releases (#397 phase 3)
Final phase of the in-app update flow. release.yml on tag pushes
fetches the APK that flutter.yml is attaching to the same release,
drops it into client/ in the build context, and the Dockerfile's
COPY client/ /app/client/ bakes it into the image.

### How it sequences

flutter.yml and release.yml both trigger on tag pushes and run in
parallel on different runners (flutter-ci vs go-ci). flutter.yml
typically finishes APK build + release attachment in 2-5 min.
release.yml polls the release page for up to 15 min for the APK to
appear, then proceeds. If the APK never lands (flutter.yml failure,
network hiccup), release.yml emits a warning and ships the image
without the bundled APK — server returns 404 from /api/client/version,
banner stays hidden, manual download from the release page still
works. Graceful degradation, not a blocker.

### Repo shape

- client/.gitkeep + client/README.md so the directory exists in git
  and the COPY in the Dockerfile always finds something to copy
- .gitignore excludes client/minstrel.apk + .version so accidental
  commits don't bloat the repo
- Dockerfile: COPY --chown=minstrel:minstrel client/ /app/client/

### Why polling vs cross-workflow trigger

Forgejo Actions' workflow_run support varies by runner version; the
polling approach is universally compatible. If/when we standardize on
a runner that handles workflow_run cleanly, the polling step can
become a `needs:` dependency.

Closes #397.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:44:08 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 a2bea8601a feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."

- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
  Both unauthenticated (matches /healthz) so install flow doesn't
  depend on a live session. APK served with proper
  application/vnd.android.package-archive Content-Type +
  http.ServeContent so Range requests work for resumable downloads
  on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
  env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
  sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
  both present (200 with correct shape), apk stream (200 with
  correct Content-Type + body bytes).

Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:35:24 -04:00
bvandeusenandClaude Opus 4.7 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 861dd8ef17 feat(web): auto-poll request progress when ingests are in flight (#369)
Half of #369 — the auto-poll piece. Inbox deferred (separate task).

Both /requests (user) and /admin/requests (admin, 'approved' tab only)
now refetch every 12s while at least one row has status='approved'.
Stops automatically when all rows settle to pending/completed/rejected.
TanStack Query's default refetchIntervalInBackground=false handles
the visibility behavior — polling pauses when the tab is hidden and
resumes on focus.

Predicate hasInFlightRequest() is exported + tested so the polling
logic is auditable independent of TanStack Query's internals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:59:38 -04:00
bvandeusenandClaude Opus 4.7 7bc254e17e feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
Phases 1+2 of the background-play resilience work. Net effect on
desktop: gap-free track transitions on slow networks, automatic
recovery from transient stalls / network errors mid-track.

- preload="metadata" → "auto" on the main audio element so the
  browser pre-buffers more aggressively.
- attachStallRetry() — listens for stalled / waiting / network-error
  events. Schedules a reload-from-current-position after delayMs
  (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK
  triggers immediate retry. Bounded by maxRetries (3 default) to
  prevent tight loops on persistent failures.
- audioLoader seam — `lib/player/audioLoader.ts` exposes a
  resolve(track) → URL + optional prefetch(track) hint. Default
  returns track.stream_url; the planned Tauri shell can swap via
  setAudioLoader() to return blob URLs backed by a Rust cache,
  mirroring the Flutter drift+LockCachingAudioSource pattern.
- Hidden prefetch <audio> element. When current track passes 50%,
  start loading queue[index+1]. On track-end the swap is gap-free
  via browser HTTP cache + audio buffer warm-up.

Phase 3 (service worker chunk cache) deliberately deferred — that
work belongs in the Tauri shell as native Rust caching, not as a
web-only investment that would compete with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:43:41 -04:00
bvandeusenandClaude Opus 4.7 1daf0b7fdf feat(web): close #358 invites RowActionsMenu wiring
The mobile-responsive design called for all 4 admin tables to use
RowActionsMenu. Requests + Quarantine + Users users-section landed
in earlier commits; the invites section in the same users page was
still rendering inline Copy/Revoke buttons. Now uses RowActionsMenu
with primary Copy + secondary Revoke (danger).

Extend expiry from the design's secondary list deferred — no
server endpoint exists yet (lib/api/admin.ts has no
extendInviteExpiry).

Closes the in-code portion of #358. Real-device verification
walkthrough at 375/414/768 + grid/safe-area checks remain
operator-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:14:18 -04:00
bvandeusenandClaude Opus 4.7 308202a1df refactor(web): extract CardActionCluster (#375 item #8 — narrowed scope)
The MediaCard consolidation in the discovery doc would have needed
~14 props to cover the differentiating bits (wrapping element a vs
button, art-shape, art-fallback strategy, click semantics, title
align/size, subtitle count). That's a god-prop blob that's harder to
reason about than three explicit cards.

Narrowed to the actually-drifted bit: the action cluster
(LikeButton + Plus + bottom-right menu slot, including the
stopPropagation defensive wrappers). One small component covers
AlbumCard / ArtistCard / CompactTrackCard so the focus-ring and
button-styling drift can't recur.

Each card keeps its own data-fetching, art container, and wrapping
element since those have load-bearing differences. The consolidation
addresses drift, not LOC for its own sake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:58:17 -04:00
bvandeusenandClaude Opus 4.7 9b4f907db6 refactor(web): sweep 24 test files to use makeTrack/makeTracks fixtures (#375)
Replaces inline TrackRef literal redefinitions with calls to the
test-utils helper. Tests that asserted on default field values
(e.g. track titles, artist names rendered in the DOM) keep explicit
overrides; tests that only need a stub for shape now use makeTrack()
with no overrides.

PlaylistCard.test.ts, PlaylistTrackRow.test.ts, and playlist.test.ts
SKIPPED — they use PlaylistTrack (with track_id/added_at), not TrackRef.
ArtistCard.svelte and +page.svelte route files (matched by initial grep)
SKIPPED — live code, not test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:58:14 -04:00
bvandeusenandClaude Opus 4.7 993bcc6a14 fix(web/test): update playlist API tests for api.* call surface (#375)
After e8eff1b migrated playlists.ts from raw apiFetch to the api.*
wrapper, three test files mocked the wrong surface:

- playlists.test.ts: GET case asserted init.method === 'GET' but
  api.get omits init.method entirely (fetch defaults to GET). Fall
  back to 'GET' when init.method is undefined.
- playlists.refresh-discover.test.ts: re-mock ./client to expose
  `api: { post: vi.fn() }` instead of `apiFetch`; assertions check
  api.post call args.
- playlists.refresh-foryou.test.ts: same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:48:04 -04:00
bvandeusenandClaude Opus 4.7 2ec54bc429 refactor(web): sweep playlist.test.ts to use emptyQuarantineMock() (#375)
Replaces the hand-rolled { subscribe, data } quarantine stub with the
emptyQuarantineMock() helper. readable() satisfies the same store
contract the page actually uses (subscription via $store), and the
playlist tests don't assert on quarantine state — the mock is purely
a transitive module-load satisfier (PlaylistTrackRow → TrackMenu).

Companion to commit 7e8d196 (likes sweep), which initially skipped
this file because it shared the hand-rolled stub shape with the likes
mock; the quarantine half can safely move to the helper.

Five other files in the original 8-file batch (PlaylistTrackRow,
TrackMenu, TrackRow, PlayerBar, CompactTrackCard) were absorbed into
that likes-sweep commit; search/tracks was absorbed into commit
dd67f28 (pageUrl sweep). hidden.test.ts SKIPPED — it imports
createMyQuarantineQuery as a vi.fn() to mockReturnValue per-test and
asserts on unflagTrack call args; the helper would defeat both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:57 -04:00
bvandeusenandClaude Opus 4.7 7e8d19606a refactor(web): sweep test files to use empty likes/quarantine mocks (#375)
Replaces duplicated inline vi.mock('$lib/api/likes', ...) blocks with
the emptyLikesMock() helper, and (where previously left in working
tree by a parallel quarantine sweep) rolls in the matching
emptyQuarantineMock() switchovers in the same files.

Together with commit dd67f28 — which already swept the four search/*
test files for likes — this completes the 18 likes-mock conversions
called out in #375.

LikeButton.test.ts kept inline — it uses a state-driven mock to
assert the toggle behavior.

SKIPPED:
- liked.test.ts: re-exports createLikedTracksInfiniteQuery and the
  album/artist variants that emptyLikesMock() doesn't provide.
- playlist.test.ts: hand-rolled { subscribe, data } stub instead of
  readable(...) — different store contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:41:15 -04:00
bvandeusenandClaude Opus 4.7 dd67f28745 refactor(web): sweep 8 test files to use pageUrlModule() (#375)
Centralizes the inline { page: { get url() { return state.pageUrl } } }
mock shape via test-utils/mocks/appState.ts. The vi.hoisted state
declaration remains per-file (vitest mock-hoisting requires module-
level declaration). Adds a $test-utils alias to svelte.config.js so
the helper can be imported without ../../../ chains.

SKIPPED (mock exposes more than url):
- Shell.test.ts, MobileNavDrawer.test.ts, admin.test.ts: static
  page: { url: ... } shape, no hoisted state to centralize.
- album.test.ts, artist.test.ts: page exposes both params and url.
- playlist.test.ts, reset-password.test.ts: page exposes params only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:39:25 -04:00
bvandeusenandClaude Opus 4.7 6e0223a9b1 refactor(web): runMutation helper for generic-toast admin handlers (#375)
Wraps the dominant pattern (try { await fn } catch { pushToast(errMessage,
'error') }) for the 5 admin/+page.svelte handlers that surface errMessage
directly: onApprove, onReject, onResolve, onDeleteFile, onDeleteLidarr.

Tighter scope than originally planned — the 3 handlers in
admin/users/+page.svelte (onToggleAutoApprove, onGenerateInvite,
onRevokeInvite) use errCode + verb prefix ("Generate failed: ${code}")
which is structurally different and would change UX wire output if
forced through the helper. Skipping them keeps behavior identical.

The helper SWALLOWS errors so callers no longer need try/finally for
busy-state — `saving = true; await runMutation(...); saving = false;`
is correct because runMutation can't throw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:39:00 -04:00
bvandeusenandClaude Opus 4.7 0eaaf66748 refactor(web): playlistTrackToRef helper to dedupe TrackRef
reconstruction (#375)

PlaylistTrackRow.svelte and PlaylistCard.toTrackRefs both rebuilt
TrackRef from PlaylistTrack with the same field-by-field literal.
Server adding a new TrackRef field would have needed both sites
updated; now one helper owns the mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:37:11 -04:00
bvandeusenandClaude Opus 4.7 633406c05b refactor(web): safeLocalStorage helper for store persistence (#375)
Three nearly-identical try/catch wrappers across theme + player stores
collapse to read()/write()/remove() in lib/util/safeLocalStorage.ts.
Sets the pattern for future stores. Caller still does parse/serialize
since the existing call sites store strings (theme preference, volume
number) — no JSON wrapper needed yet.

persisted.ts left alone — its JSON-payload + per-key-suffix shape is
distinct enough to keep self-contained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:45 -04:00
bvandeusenandClaude Opus 4.7 2357df83be refactor(web): add test-utils helpers for likes/quarantine/track/appState mocks (#375)
Pre-sweep checkpoint. Adds:
- fixtures/track.ts        — makeTrack() / makeTracks(n) (18 files duplicate today)
- mocks/likes.ts           — emptyLikesMock() (19 files duplicate today)
- mocks/quarantine.ts      — emptyQuarantineMock() (9 files duplicate today)
- mocks/appState.ts        — pageUrlModule(state) (8 files duplicate today)

The sweep commits that follow update test files to use these helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:31:53 -04:00
bvandeusenandClaude Opus 4.7 e8eff1baa1 refactor(web): migrate playlists.ts to api.* wrapper (#375)
Drop 9 hand-rolled apiFetch calls with redundant Content-Type
headers + manual JSON.stringify. The api.* wrapper in client.ts
already handles both. Identical wire shape; existing tests cover
all endpoints.

Also makes api.del generic so DELETE endpoints with typed returns
(playlist track removal) can use the wrapper instead of raw
apiFetch. Default `T = null` preserves existing callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:30:34 -04:00
bvandeusenandClaude Opus 4.7 714039bae5 refactor(web): drive vite-injected theme-color from tokens.json (#375)
Closes the last theme-color hardcode. Runtime side already imports
tokens.colors.{dark,light}.obsidian via applyMetaThemeColor; build
side now matches via JSON import-attributes. Drops the // TODO(#375)
tripwire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:29:48 -04:00
bvandeusenandClaude Opus 4.7 65057473c6 refactor(web): break auth ↔ player import cycle via session-end signal (#375)
auth/store.svelte.ts no longer imports from $lib/player. Logout
broadcasts via auth/sessionEnd.svelte.ts (a tiny tick + outgoing
userId pair); player subscribes through $effect.root and owns the
player-specific teardown (clearPersistedQueue + playQueue([]) +
closeQueueDrawer()).

Cycle fully inverted — auth is now a pure leaf for player; future
session-end consumers (download cache, recent-search history) plug
in by adding their own effect on the same signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:29:30 -04:00
bvandeusenandClaude Opus 4.7 94727f40bb fix(flutter): analyze errors after Plan C provider migrations
- cache_first.dart: backtick-fence List<T> doc comment to dodge
  unintended_html_in_doc_comment.
- library_providers.dart:163: switch single-row insert to
  insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict*
  variant.
- 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>,
  not Create<Future<T>>. Wrap data emissions in Stream.value(...).
- artist_detail_screen_test: add models/album.dart import for AlbumRef
  type annotation.
- track_actions_sheet_test: drop _StubLiked extends LikedIdsController
  (notifier no longer exists post-migration); override with
  Stream.value(LikedIds(...)) instead.
- like_button_test: rollback assertion now requires drift writes via
  LikesController. Skip under _skipDrift until libsqlite3-dev lands on
  the runner image (Fable #399). Replace stale .notifier reference
  with likesControllerProvider for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:27 -04:00
bvandeusenandClaude Opus 4.7 98fdd9dcdf feat(flutter/cache): migrate playlist providers to drift-first (#357 plan C)
playlistsListProvider — StreamProvider over cached_playlists. Returns
all user-owned + others' public playlists. The 'kind' family arg only
affects the REST cold-cache fetch (server-side filter); drift can't
distinguish 'user' from 'system' kind because systemVariant isn't
persisted. v1 limitation: add-to-playlist sheet may briefly show
system playlists too. Follow-up: add systemVariant column + schema bump.

playlistDetailProvider — async* over the playlist watch stream + a
one-shot tracks query per emission (joins playlist_tracks → tracks →
artists → albums for snapshot fields). Cold-cache fallback inserts
playlist + playlist_tracks rows in one batch, then awaits re-emission.

Pull-to-refresh on these screens now triggers re-subscription rather
than forcing a REST fetch — drift is the source of truth and stays
fresh via SyncController's delta sync. Documented behaviour shift; if
operator wants force-refetch we can add it via a sync trigger later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:27:49 -04:00
bvandeusenandClaude Opus 4.7 08c16d6d86 fix(flutter): point like consumers at likesControllerProvider
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>
2026-05-10 11:21:47 -04:00
bvandeusenandClaude Opus 4.7 8a6c926ea7 feat(flutter/cache): migrate likedIdsProvider to drift-first; LikesController for mutations
Splits the previous LikedIdsController into two:

- likedIdsProvider — StreamProvider reading from cached_likes via
  drift watch(). Reactive: SyncController writes propagate
  automatically. Empty + online triggers REST cold-cache fallback
  that populates cached_likes via insertOrIgnore (sync may have
  already written some rows).

- likesControllerProvider (new) — exposes toggle(LikeKind, id).
  Optimistic: writes drift first (UI updates instantly via the
  watch stream), then REST. Rolls back drift on REST failure.

Two consumer updates: like_button.dart + track_actions_sheet.dart
switch from likedIdsProvider.notifier.toggle to
likesControllerProvider.toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:20:59 -04:00
bvandeusenandClaude Opus 4.7 fc83c7b514 feat(flutter/cache): migrate artistAlbums/artistTracks/album to drift-first
Three providers all become StreamProviders driven by drift watch():

- artistAlbumsProvider: cacheFirst over the join of cached_albums +
  cached_artists for artist_name on each row.
- artistTracksProvider: cacheFirst over the join of cached_tracks +
  cached_artists + cached_albums for snapshot fields.
- albumProvider: composite ({album, tracks}) shape, so uses async*
  with two queries (album + tracks) for cleaner reactive behavior.
  Cold-cache fallback inlined; populates both album and tracks tables
  in one batch.

.future call site in artist_detail_screen.dart left as-is —
StreamProvider.future returns the next emission with the same
signature, so no consumer change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:19:47 -04:00
bvandeusenandClaude Opus 4.7 947d4401a6 feat(flutter/cache): migrate artistProvider to drift-first (#357 plan C)
artistProvider becomes a StreamProvider backed by drift's cached_artists
watch(). Empty + online triggers REST fallback; populates drift; watch
re-emits. Empty + offline yields a sentinel ArtistRef(id: '', name: '');
surfaces should already check state.value?.id.isNotEmpty.

AsyncValue<ArtistRef> shape unchanged so .when callsites keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:59 -04:00
bvandeusenandClaude Opus 4.7 4eb9935f8e feat(flutter/cache): cacheFirst helper + drift↔model adapters (#357 plan C)
Foundation for the provider migrations. cacheFirst<D, T> wraps the
drift.watch() + REST cold-cache fallback pattern: yields cached rows
when present, kicks off REST fetch + drift populate when empty +
online, yields empty when offline. REST failures swallow to empty so
callers can surface errors via toast.

adapters.dart adds CachedX → XRef extension methods + reverse
XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters
accept some loss of server-derived fields (coverUrl, streamUrl,
ownerUsername) — UI already handles empty values; cold-cache fallback
briefly shows the real values before drift takes over.

cache_first_test covers all 4 branches (non-empty, empty+online,
empty+offline, REST failure). adapters_test covers basic round-trips.
Both safe to run on CI runner — no drift open required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:29 -04:00
bvandeusenandClaude Opus 4.7 2940e95b64 fix(flutter/test): skip TrackRow smoke test under the drift cohort
Pending-timer failure: CachedIndicator (now in TrackRow) reads
audioCacheManagerProvider, which constructs AppDb via drift_flutter's
driftDatabase(), which calls libsqlite3 — missing on the flutter-ci
runner. The async chain leaves a pending timer at test teardown.

Skip cohort matches sync_controller / audio_cache_manager / storage_section
tests; all re-enable once Fable #399 lands the runner image fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:49:03 -04:00
bvandeusenandClaude Opus 4.7 a3db6c9453 fix(flutter/test): testWidgets skip takes bool, not String
CI analyze flagged storage_section_test.dart's `skip: _skipDrift` calls.
testWidgets has signature `skip: bool?`; only test() takes `String?` as
the skip-reason. Changed _skipDrift in this file to `const bool = true`
and moved the rationale into a comment. The other two drift-cohort
files (sync_controller_test, audio_cache_manager_test) use test() so
their String const is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:41:48 -04:00
bvandeusenandClaude Opus 4.7 1b223d3891 fix(flutter/test): skip drift tests pending libsqlite3 in CI runner image
CI's flutter-ci runner doesn't ship libsqlite3.so. drift's NativeDatabase
fails at first use ("Failed to load dynamic library 'libsqlite3.so'").
Affected files:
  - test/cache/sync_controller_test.dart (4 tests)
  - test/cache/audio_cache_manager_test.dart (5 tests)
  - test/settings/storage_section_test.dart (2 tests, was silently
    succeeding because the drift call was best-effort but emitted
    multiple-AppDb warnings)

All 11 marked with skip: '...' + a top-level @Tags(['drift']) library
declaration so they can be re-enabled by tag once the runner image has
libsqlite3-dev installed (or once we move VM tests to sqlite3/wasm).
On-device verification covers the actual cache + sync logic.

Plus two collateral fixes:
  - test/cache/connectivity_provider_test.dart: connectivity_plus needs
    a platform channel that doesn't exist in unit tests; reduced to a
    non-null import smoke check.
  - test/library/widgets_smoke_test.dart: TrackRow now contains
    CachedIndicator (ConsumerWidget); wrapped TrackRow test in
    ProviderScope.

Filing follow-up for the runner image fix in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:27:49 -04:00
bvandeusenandClaude Opus 4.7 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 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
bvandeusenandClaude Opus 4.7 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