Compare commits

...

35 Commits

Author SHA1 Message Date
bvandeusen 0d009b34e2 Merge pull request 'release v2026.05.12.1: Discover surface + nav fixes + cache hygiene' (#42) from dev into main 2026-05-12 03:58:43 +00:00
bvandeusen 5fc04f14b7 fix(flutter): full-player kebab nav + restore artistAlbums SWR
Two unrelated issues, batched.

Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.

Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
  onNavigate: (path) async {
    await Navigator.of(context).maybePop();
    if (context.mounted) GoRouter.of(context).push(path);
  }
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.

Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.

Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
2026-05-11 23:54:11 -04:00
bvandeusen a09b636e1a fix(flutter): full player kebab Go to album/artist navigates cleanly
Same root cause as the /queue duplicate-page-key crash: /now-playing
is a top-level route (lives outside the ShellRoute), but
/artists/:id and /albums/:id are shell-children. Pushing a shell-
child from a top-level route makes go_router attempt to mount a
second ShellRoute on top of the active one, leaving navigation in a
broken state. The mini player works because it's already inside the
shell.

Add an optional onBeforeNavigate callback to TrackActionsSheet
(forwarded through TrackActionsButton). When set, fires after
sheet.pop() and before context.push() of the destination route.

Wire the full player's TrackActionsButton with onBeforeNavigate:
() => Navigator.of(context).maybePop() so /now-playing dismisses
itself before the detail route is pushed. Result: clean navigation
into the destination, mini player visible underneath as expected.

Mini player keeps the default (no callback) since it's already in
the shell.
2026-05-11 23:50:42 -04:00
bvandeusen 356f8f5d6c test(web): update home placeholder counts for new Discover slot
5-slot system row (For-You, Discover, 3× Songs-like) means:
- empty state: 5 placeholders (was 4)
- For-You only: 4 placeholders (was 3) — Discover + 3 songs-like
2026-05-11 23:46:07 -04:00
bvandeusen 96aa2407d9 feat(home): surface Discover system playlist as a tile (web + flutter)
Server has been generating system_variant='discover' playlists since
M6a (internal/playlists/system.go and POST
/api/playlists/system/discover/refresh) but neither client surfaced
it. The Flutter home filtered system playlists by exact match on
'for_you' and 'songs_like_artist', dropping Discover. The web home
did the same. Tiles were generated server-side and silently
discarded by both clients.

Add a Discover slot to the playlists row in both:
- Flutter: 5-slot row now (For You, Discover, 3× Songs-like) with
  matching placeholder state machine.
- Web: same shape, same placeholderVariant() call.

When the engine has built it, the real playlist tile renders;
otherwise a placeholder with the same building/pending/failed
status semantics as the other system tiles.
2026-05-11 23:41:01 -04:00
bvandeusen e856172d60 chore(flutter): drop success-path diagnostic prints
Cleanup pass on the noisy debugPrints we added during the recent
debugging sessions. Remaining prints are error-path only:
- AlbumCoverCache fetch failed
- album/playlist cold-cache fetch failed
- audio_handler playbackEventStream error
- audio_handler queue supersession (rare race)
- audio_handler forward/backward fill failed
- artist_detail play failed
- cacheFirst fetchAndPopulate failed (only when tag is set)

Removed per-event chatter:
- audio_handler player-state and processingState subscribers
- audio_handler timing measurements (built initial / setAudioSources
  / forward fill / backward fill)
- audio_handler cache hit/miss per-track (fired N times per play)
- audio_handler register stream cache (verifiable via Settings)
- audio_handler.configure log
- albumProvider step-by-step lines (drift miss / online / drift hit /
  album hit but no tracks)
- playlistDetailProvider step-by-step lines (calling get / drift
  write done / 404 evicted / drift hit / playlist hit but no tracks)
- playlistsListProvider wire returned + drift rows lines
- playTracks stage timings (serverUrl / token / configure / setQueue
  / play returned)
- metadataPrefetcher warming N artists
- artist_detail play tapped — N tracks success log
- cacheFirst step-by-step (drift hit / drift miss / online / done)

`tag` parameter on cacheFirst preserved for ad-hoc instrumentation.
2026-05-11 23:16:42 -04:00
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 f1b4652c77 Merge pull request 'release v2026.05.11.3: caching, perf, playlist polish' (#41) from dev into main 2026-05-12 00:35:48 +00: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 e610948307 Merge pull request 'release v2026.05.11.2: signing key + library infinite scroll' (#40) from dev into main 2026-05-11 19:51:41 +00: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 fb811804d2 Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main 2026-05-11 17:47:41 +00: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
34 changed files with 1548 additions and 313 deletions
+27
View File
@@ -72,6 +72,25 @@ jobs:
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
run: flutter build apk --debug
- name: Decode signing keystore
if: startsWith(github.ref, 'refs/tags/v')
# Reconstructs the release keystore from a base64-encoded
# CI secret so every tagged build is signed with the same
# key. Without this, each CI runner would generate its own
# debug keystore and Android would refuse to upgrade an
# existing install (signature mismatch).
shell: bash
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
run: |
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
exit 1
fi
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
- name: Build release APK
if: startsWith(github.ref, 'refs/tags/v')
# Inject the tag (sans leading 'v') as the build's --build-name
@@ -79,7 +98,15 @@ jobs:
# reports — otherwise the in-app update banner would always
# think the user is behind because pubspec.yaml's static
# version drifts from the actual release tag.
#
# ANDROID_KEYSTORE_PATH is set by the previous step;
# build.gradle.kts picks it up and signs the release APK with
# the operator's persistent keystore.
shell: bash
env:
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
TAG="${GITHUB_REF#refs/tags/v}"
flutter build apk --release --build-name="${TAG}"
+30 -3
View File
@@ -30,11 +30,38 @@ android {
versionName = flutter.versionName
}
// Real release signing config — populated only when CI exports
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
// matching password/alias env vars. Without these (local
// `flutter build apk --release` runs), the release build falls
// through to the debug keystore so local builds still work.
//
// Why this matters: Flutter's default `flutter run --release` and
// CI's earlier setup both signed with the per-machine debug
// keystore (~/.android/debug.keystore). Every CI runner generated
// its own debug key, so consecutive release APKs had different
// signatures and Android refused to upgrade an existing install.
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (keystorePath != null && keystorePath.isNotEmpty()) {
signingConfigs {
create("release") {
storeFile = file(keystorePath)
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
signingConfigs.getByName("release")
} else {
// Local-only fallback so `flutter run --release` works
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
signingConfigs.getByName("debug")
}
}
}
}
@@ -12,9 +12,16 @@ class LibraryListsApi {
final Dio _dio;
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
// Server mounts the artists list at /api/artists (handleListArtists),
// not /api/library/artists. Albums use /api/library/albums for
// historical reasons; the paths aren't symmetric.
final r = await _dio.get<Map<String, dynamic>>(
'/api/library/artists',
queryParameters: {'limit': limit, 'offset': offset},
'/api/artists',
queryParameters: {
'limit': limit,
'offset': offset,
'sort': 'alpha',
},
);
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
}
@@ -0,0 +1,27 @@
import 'package:dio/dio.dart';
import '../../models/track.dart';
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
/// scored by the server's recommendation engine. The shape matches
/// what `playerActions.playTracks` expects, so a radio start is just
/// one fetch + one playTracks call.
class RadioApi {
RadioApi(this._dio);
final Dio _dio;
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/radio',
queryParameters: {
'seed_track': trackId,
if (limit != null) 'limit': limit,
},
);
final raw = (r.data?['tracks'] as List?) ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
}
+6
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cache/metadata_prefetcher.dart';
import 'cache/prefetcher.dart';
import 'cache/sync_controller.dart';
import 'shared/routing.dart';
@@ -27,6 +28,11 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
// ignore: unawaited_futures
ref.read(syncControllerProvider.notifier).sync();
ref.read(prefetcherProvider);
// Metadata prefetcher: when /api/home returns, fire background
// albumProvider/artistProvider reads for the top-N items in
// each section so subsequent taps are drift hits, not network
// round trips.
ref.read(metadataPrefetcherProvider);
});
}
+7 -1
View File
@@ -88,6 +88,12 @@ extension TrackRefDriftWrite on TrackRef {
extension CachedPlaylistAdapter on CachedPlaylist {
/// `ownerUsername` is server-derived; cache stores the userId only.
/// Pass empty unless a join supplies it.
/// `coverUrl` is reconstructed deterministically from the playlist
/// id — the server serves the cached collage at this path
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
/// the server hasn't built a collage yet (system playlists with no
/// tracks at build time), the endpoint 404s and PlaylistCard's
/// ServerImage falls back to its slate placeholder.
Playlist toRef({String ownerUsername = ''}) => Playlist(
id: id,
userId: userId,
@@ -96,7 +102,7 @@ extension CachedPlaylistAdapter on CachedPlaylist {
isPublic: isPublic,
systemVariant: systemVariant,
trackCount: trackCount,
coverUrl: '', // server-derived; cache doesn't persist
coverUrl: '/api/playlists/$id/cover',
ownerUsername: ownerUsername,
createdAt: '',
updatedAt: '',
+39 -6
View File
@@ -83,6 +83,27 @@ class AudioCacheManager {
return path;
}
/// Registers a file already on disk in the cache index. Intended for
/// the streaming path (LockCachingAudioSource) which writes the file
/// itself; we need an index row so eviction can find and delete it
/// when usage exceeds the cap. `source` defaults to incidental so
/// stream-cached tracks are first to be evicted.
Future<void> registerStreamCache(
String trackId,
String path,
int sizeBytes, {
CacheSource source = CacheSource.incidental,
}) async {
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: trackId,
path: path,
sizeBytes: sizeBytes,
source: source,
),
);
}
/// Removes a track from the index AND deletes the file.
Future<void> unpin(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
@@ -96,13 +117,25 @@ class AudioCacheManager {
.go();
}
/// Total bytes used by the cache.
/// Total bytes used by the cache. Walks the cache directory directly
/// instead of summing the index, because the streaming-as-you-play
/// path (audio_handler's LockCachingAudioSource) writes files
/// without registering an index row. The index sum would always
/// understate (often to zero) for users who only stream.
Future<int> usageBytes() async {
final result = await _db.customSelect(
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
readsFrom: {_db.audioCacheIndex},
).getSingle();
return result.read<int>('total');
final dir = Directory(await _tracksDir());
if (!await dir.exists()) return 0;
var total = 0;
await for (final entity in dir.list(followLinks: false)) {
if (entity is File) {
try {
total += await entity.length();
} catch (_) {
// Race against concurrent writes / deletes — just skip.
}
}
}
return total;
}
/// Evicts files until usage ≤ targetBytes. Eviction order:
+7 -9
View File
@@ -19,6 +19,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
// `tag` parameter is preserved for future ad-hoc instrumentation.
// Normal operation only logs failure paths so the per-screen log
// noise stays low.
/// Wraps the watch + cold-cache fallback pattern. Generic over:
/// D — the drift row type (or TypedResult for joins)
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
@@ -43,13 +47,9 @@ Stream<T> cacheFirst<D, T>({
// refresh for this stream subscription, so we don't fire one on every
// drift re-emission (otherwise the populate cycles forever).
var revalidated = false;
void log(String msg) {
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
}
await for (final rows in driftStream) {
if (rows.isNotEmpty) {
log('drift hit (${rows.length} rows)');
yield toResult(rows);
// Stale-while-revalidate: yield cache immediately, then kick off
// a REST refresh in the background. Drift watch() picks up the
@@ -62,20 +62,18 @@ Stream<T> cacheFirst<D, T>({
}
continue;
}
log('drift miss; checking connectivity');
if (await isOnline()) {
log('online; calling fetchAndPopulate');
try {
await fetchAndPopulate();
log('fetchAndPopulate done; awaiting drift re-emit');
// The drift watch() stream re-emits with the populated rows on
// the next loop iteration. Don't yield here.
} catch (e, st) {
log('fetchAndPopulate failed: $e\n$st');
if (tag != null) {
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
}
yield toResult(rows); // empty result; caller surfaces error
}
} else {
log('offline; yielding empty');
yield toResult(rows); // empty result; offline
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart';
import '../models/home_data.dart';
/// Pre-warms the drift cache for likely-tap targets. Conservative on
/// purpose: only warms artistProvider rows (single row, single round
/// trip per id) and only ever fires once per id per session. Album
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
/// list when missing, and pre-warming N albums fans out N parallel
/// "fetch tracks" round trips that compete with the user's actual
/// playback request for bandwidth.
class MetadataPrefetcher {
MetadataPrefetcher(this._ref) {
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
next.whenData(_warmHome);
});
}
final Ref _ref;
/// Per-session dedupe so re-rendering a screen (every UI rebuild
/// fires the data: callback) doesn't trigger N more fetches for
/// ids we've already warmed.
final Set<String> _warmedArtists = {};
/// Cap per section. Covers what fits on screen without scrolling.
static const _topN = 8;
/// Pre-warm artists. Albums are intentionally not pre-warmed —
/// see class comment.
void warmArtists(Iterable<String> ids) {
var n = 0;
for (final id in ids) {
if (id.isEmpty) continue;
if (!_warmedArtists.add(id)) continue; // already warmed
if (n++ >= _topN) break;
_swallow(_ref.read(artistProvider(id).future));
}
}
void _warmHome(HomeData h) {
final artistIds = <String>{};
for (final a in h.recentlyAddedAlbums.take(_topN)) {
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
}
for (final a in h.rediscoverAlbums.take(_topN)) {
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
}
for (final ar in h.rediscoverArtists.take(_topN)) {
if (ar.id.isNotEmpty) artistIds.add(ar.id);
}
for (final ar in h.lastPlayedArtists.take(_topN)) {
if (ar.id.isNotEmpty) artistIds.add(ar.id);
}
for (final t in h.mostPlayedTracks.take(_topN)) {
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
}
warmArtists(artistIds);
}
/// Discards the return value and any error from a fire-and-forget
/// provider read. We don't care about the value here — we only want
/// the side effect of writing drift.
void _swallow(Future<Object?> f) {
f.then<void>((_) {}).onError((_, __) {});
}
}
/// Read once at app start to activate the prefetcher (e.g. wire it
/// from a top-level Consumer or main.dart container override).
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
return MetadataPrefetcher(ref);
});
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../models/album.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart';
@@ -12,18 +13,78 @@ import 'library_providers.dart';
import 'widgets/track_row.dart';
class AlbumDetailScreen extends ConsumerWidget {
const AlbumDetailScreen({required this.id, super.key});
const AlbumDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional album reference passed by the caller (typically the
/// tile they tapped) so the header can render immediately while
/// the full provider loads tracks. Hydration only — provider data
/// still wins once it arrives.
final AlbumRef? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final live = ref.watch(albumProvider(id));
// Pick the best header info available: live data > seed > nothing.
final headerTitle = live.value?.album.title.isNotEmpty == true
? live.value!.album.title
: seed?.title ?? '';
final headerArtist = live.value?.album.artistName.isNotEmpty == true
? live.value!.album.artistName
: seed?.artistName ?? '';
Widget header() => Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 96,
height: 96,
child: ServerImage(
url: '/api/albums/$id/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
headerTitle,
style: TextStyle(
color: fs.parchment,
fontFamily: fs.display.fontFamily,
fontSize: 22,
),
),
if (headerArtist.isNotEmpty)
Text(headerArtist, style: TextStyle(color: fs.ash)),
],
),
),
]),
);
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: ref.watch(albumProvider(id)).when(
body: live.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => seed != null
? ListView(children: [
header(),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
])
: const Center(child: CircularProgressIndicator()),
data: (r) => ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../api/endpoints/likes.dart';
import '../likes/like_button.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
@@ -12,22 +13,49 @@ import 'library_providers.dart';
import 'widgets/album_card.dart';
class ArtistDetailScreen extends ConsumerWidget {
const ArtistDetailScreen({required this.id, super.key});
const ArtistDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional artist reference from the caller. Lets the screen render
/// the name + avatar immediately while albums load.
final ArtistRef? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final artist = ref.watch(artistProvider(id));
final albums = ref.watch(artistAlbumsProvider(id));
// Resolve which artist info to render in the header. Live wins
// when present and populated; seed fills the gap during the
// first frame after navigation.
final liveArtist = artist.value;
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
return Scaffold(
appBar: AppBar(),
backgroundColor: fs.obsidian,
body: artist.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Center(child: CircularProgressIndicator()),
data: (a) => ListView(children: [
// While loading: render header from seed if available so the
// page isn't blank.
loading: () => seed == null
? const Center(child: CircularProgressIndicator())
: _artistBody(context, ref, seed!, albums, fs),
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
),
);
}
Widget _artistBody(
BuildContext context,
WidgetRef ref,
ArtistRef a,
AsyncValue<List<AlbumRef>> albums,
FabledSwordTheme fs,
) =>
ListView(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
@@ -64,10 +92,31 @@ class ArtistDetailScreen extends ConsumerWidget {
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
onPressed: () async {
final tracks = await ref.read(artistTracksProvider(id).future);
if (tracks.isEmpty) return;
final shuffled = [...tracks]..shuffle();
ref.read(playerActionsProvider).playTracks(shuffled);
try {
final tracks =
await ref.read(artistTracksProvider(id).future);
if (tracks.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No tracks found for this artist yet.')),
);
}
return;
}
final shuffled = [...tracks]..shuffle();
await ref
.read(playerActionsProvider)
.playTracks(shuffled);
} catch (e) {
debugPrint('artist_detail: play failed: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't start playback: $e")),
);
}
}
},
),
),
@@ -81,7 +130,8 @@ class ArtistDetailScreen extends ConsumerWidget {
albums.when(
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
data: (list) => LayoutBuilder(builder: (ctx, constraints) {
data: (list) {
return LayoutBuilder(builder: (ctx, constraints) {
const cols = 3;
const sidePad = 8.0;
const gap = 8.0;
@@ -89,10 +139,11 @@ class ArtistDetailScreen extends ConsumerWidget {
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
cols;
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
// ≈ 36) + small fudge. Artist line is suppressed in this
// ≈ 36) + slack. Artist line is suppressed in this
// grid (showArtist: false) since the page header already
// names the artist.
final cellH = (cellW - 16) + 8 + 36 + 4;
// names the artist. Slack is generous on purpose — line-
// height variations would otherwise overflow by 1px.
final cellH = (cellW - 16) + 8 + 36 + 8;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -111,16 +162,15 @@ class ArtistDetailScreen extends ConsumerWidget {
width: cellW,
titleMaxLines: 2,
showArtist: false,
onTap: () => context.push('/albums/${album.id}'),
onTap: () =>
context.push('/albums/${album.id}', extra: album),
);
},
);
}),
});
},
),
]),
),
);
}
]);
}
/// Renders the artist's avatar. Server-emitted coverUrl wins when
+97 -14
View File
@@ -12,7 +12,6 @@ import '../models/track.dart';
import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/delayed_loading.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
@@ -50,12 +49,7 @@ class HomeScreen extends ConsumerWidget {
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const DelayedLoading(
isLoading: true,
whenReady: SizedBox.shrink(),
whileDelayed:
Center(child: CircularProgressIndicator()),
),
loading: () => _HomeSkeleton(fs: fs),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(
@@ -141,7 +135,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
? _RealPlaylist(forYou)
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
// Slots 2-4: Songs-like (real first, padded to 3).
// Slot 2: Discover. Server emits this with system_variant='discover'
// when the recommendation engine has eligible tracks; before then,
// show a placeholder in the same "building/pending" state machine
// as For-You.
final discover = findFirst((p) => p.systemVariant == 'discover');
out.add(discover != null
? _RealPlaylist(discover)
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
// Slots 3-5: Songs-like (real first, padded to 3).
final songsLike = ownedAll
.where((p) => p.systemVariant == 'songs_like_artist')
.take(3)
@@ -190,7 +193,7 @@ class _RecentlyAddedSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -220,7 +223,7 @@ class _RediscoverSection extends StatelessWidget {
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
context.push('/albums/${a.id}', extra: a),
))
.toList(),
),
@@ -232,7 +235,7 @@ class _RediscoverSection extends StatelessWidget {
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
),
@@ -291,7 +294,8 @@ class _LastPlayedSection extends StatelessWidget {
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
);
@@ -324,8 +328,87 @@ class _EmptySection extends StatelessWidget {
}
}
void _push(BuildContext context, String path) {
context.push(path);
/// Cold-start skeleton. Renders the same shape as the real home (a few
/// section titles + card-sized placeholders) so the page feels alive
/// while /api/home is in flight, instead of a 30-second blank spinner.
/// Each section drops in independently as data arrives via the
/// individual providers.
class _HomeSkeleton extends StatelessWidget {
const _HomeSkeleton({required this.fs});
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
children: [
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
const SizedBox(height: 140),
],
);
}
}
class _SkeletonSection extends StatelessWidget {
const _SkeletonSection({
required this.fs,
required this.title,
required this.cardWidth,
});
final FabledSwordTheme fs;
final String title;
final double cardWidth;
@override
Widget build(BuildContext context) {
final coverSize = cardWidth - 16;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
),
SizedBox(
height: coverSize + 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
],
),
),
),
),
]);
}
}
List<List<T>> _chunk<T>(List<T> items, int size) {
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
@@ -64,6 +66,10 @@ final artistProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// No alwaysRefresh: artist rows don't change frequently, and the
// metadata prefetcher creates many subscriptions in parallel —
// each silently re-fetching once would saturate the request
// pipeline behind the user's actual playback request.
tag: 'artist($id)',
);
});
@@ -95,6 +101,14 @@ final artistAlbumsProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: re-fetch the full album list on every visit. Without this,
// a previously-incomplete drift cache (e.g. user had only opened
// one album by this artist, so cachedAlbums had just that row)
// would render forever as a partial list. The prefetcher only
// warms artistProvider (single row), so this provider isn't
// mass-instantiated and the storm risk that motivated dropping
// alwaysRefresh elsewhere doesn't apply here.
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
@@ -128,8 +142,10 @@ final artistTracksProvider =
albumTitle: album?.title ?? '',
);
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artistTracks($artistId)',
);
});
@@ -160,6 +176,7 @@ final albumProvider = StreamProvider.family<
// Once-per-subscription guard so we don't re-fetch in a loop if the
// server genuinely returns zero tracks (or if the fetch fails).
var fetchAttempted = false;
// (revalidated flag removed; see SWR note below the yield.)
Future<bool> isOnline() async {
try {
@@ -178,11 +195,9 @@ final albumProvider = StreamProvider.family<
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(libraryApiProvider.future);
debugPrint('albumProvider($albumId): calling getAlbum');
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
// Collect every artist mentioned by the album + its tracks so
// the JOINs that drive both the album header and the track rows
// have something to bind to. Without this, drift returns null
@@ -215,7 +230,6 @@ final albumProvider = StreamProvider.family<
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
return true;
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
@@ -226,7 +240,6 @@ final albumProvider = StreamProvider.family<
await for (final albumRows in albumQuery.watch()) {
// Case 1: no album row at all → cold-fetch.
if (albumRows.isEmpty) {
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
if (fetchAttempted) {
// Already tried and got nothing back.
yield (
@@ -236,10 +249,7 @@ final albumProvider = StreamProvider.family<
continue;
}
fetchAttempted = true;
final online = await isOnline();
debugPrint('albumProvider($albumId): online=$online');
if (!online) {
debugPrint('albumProvider($albumId): offline, yielding empty');
if (!await isOnline()) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
@@ -256,7 +266,6 @@ final albumProvider = StreamProvider.family<
// On success, drift watch re-emits with rows; loop continues.
continue;
}
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
final albumRow = albumRows.first;
final album = albumRow.readTable(db.cachedAlbums).toRef(
@@ -271,7 +280,6 @@ final albumProvider = StreamProvider.family<
// fetchAndPopulate so the album becomes complete; drift watch
// re-emits and we land in the populated branch on the next pass.
if (trackRows.isEmpty && !fetchAttempted) {
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
@@ -290,6 +298,14 @@ final albumProvider = StreamProvider.family<
);
}).toList();
// Note: NO SWR here on purpose. Prior code kicked a background
// refresh on every first cache hit, which combined with the
// metadata prefetcher meant every prewarmed album id triggered
// an extra fetch even when drift was already populated. Tracks
// and album metadata don't change on the same timescale as
// playlists; a stale read is fine until the user invalidates
// (pull-to-refresh) or the album is genuinely re-fetched.
yield (album: album, tracks: tracks);
}
});
+200 -46
View File
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../api/endpoints/library_lists.dart';
import '../api/endpoints/likes.dart';
import '../api/endpoints/me.dart';
import '../cache/metadata_prefetcher.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/album.dart';
import '../models/artist.dart';
@@ -37,13 +38,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
return MeApi(await ref.watch(dioProvider.future));
});
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
});
/// Paginated artist list. AsyncNotifier so the screen can call
/// `loadMore()` when the scroll approaches the bottom; subsequent
/// pages append to the existing items rather than replacing them.
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
static const _pageSize = 50;
bool _loadingMore = false;
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
});
@override
Future<wire.Paged<ArtistRef>> build() async {
final api = await ref.watch(_libraryListsApiProvider.future);
return api.listArtists(limit: _pageSize, offset: 0);
}
Future<void> loadMore() async {
if (_loadingMore) return;
final cur = state.value;
if (cur == null) return;
if (cur.items.length >= cur.total) return;
_loadingMore = true;
try {
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listArtists(
limit: _pageSize,
offset: cur.items.length,
);
state = AsyncData(wire.Paged(
items: [...cur.items, ...next.items],
total: next.total,
limit: next.limit,
offset: 0,
));
} catch (_) {
// Swallow — the next near-bottom event will retry. The current
// partial list stays visible.
} finally {
_loadingMore = false;
}
}
}
final _libraryArtistsProvider = AsyncNotifierProvider<
_LibraryArtistsNotifier,
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
static const _pageSize = 50;
bool _loadingMore = false;
@override
Future<wire.Paged<AlbumRef>> build() async {
final api = await ref.watch(_libraryListsApiProvider.future);
return api.listAlbums(limit: _pageSize, offset: 0);
}
Future<void> loadMore() async {
if (_loadingMore) return;
final cur = state.value;
if (cur == null) return;
if (cur.items.length >= cur.total) return;
_loadingMore = true;
try {
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listAlbums(
limit: _pageSize,
offset: cur.items.length,
);
state = AsyncData(wire.Paged(
items: [...cur.items, ...next.items],
total: next.total,
limit: next.limit,
offset: 0,
));
} catch (_) {
// Swallow — next scroll event will retry.
} finally {
_loadingMore = false;
}
}
}
final _libraryAlbumsProvider = AsyncNotifierProvider<
_LibraryAlbumsNotifier,
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
@@ -131,25 +208,53 @@ class _ArtistsTab extends ConsumerWidget {
return ref.watch(_libraryArtistsProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (page) => page.items.isEmpty
data: (page) {
// Warm details for the first screenful so taps are instant.
ref
.read(metadataPrefetcherProvider)
.warmArtists(page.items.map((a) => a.id));
return page.items.isEmpty
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) => ArtistCard(
artist: page.items[i],
onTap: () => ctx.push('/artists/${page.items[i].id}'),
onRefresh: () async {
ref.invalidate(_libraryArtistsProvider);
await ref.read(_libraryArtistsProvider.future);
},
// Listen for scroll-near-bottom and ask the notifier
// to fetch the next page. 800px lookahead so the next
// page lands before the user reaches the visible end.
child: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels >=
n.metrics.maxScrollExtent - 800) {
ref
.read(_libraryArtistsProvider.notifier)
.loadMore();
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final artist = page.items[i];
return ArtistCard(
artist: artist,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
},
),
),
),
);
},
);
}
}
@@ -163,25 +268,66 @@ class _AlbumsTab extends ConsumerWidget {
return ref.watch(_libraryAlbumsProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (page) => page.items.isEmpty
data: (page) {
return page.items.isEmpty
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) => AlbumCard(
album: page.items[i],
onTap: () => ctx.push('/albums/${page.items[i].id}'),
),
),
),
onRefresh: () async {
ref.invalidate(_libraryAlbumsProvider);
await ref.read(_libraryAlbumsProvider.future);
},
// Same responsive 3-up grid as the artist detail
// album list — LayoutBuilder + AlbumCard sized to the
// cell, mainAxisExtent matched to actual card height.
child: LayoutBuilder(builder: (ctx, constraints) {
const cols = 3;
const sidePad = 8.0;
const gap = 8.0;
final cellW = (constraints.maxWidth -
sidePad * 2 -
gap * (cols - 1)) /
cols;
// cover (cellW - 16) + gap (8) + 2-line title (~36)
// + artist line (~16) + slack. Slack is generous on
// purpose — line-height + font scaling variations
// would otherwise overflow the cell by a pixel
// (logged as a noisy RenderFlex warning).
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels >=
n.metrics.maxScrollExtent - 800) {
ref
.read(_libraryAlbumsProvider.notifier)
.loadMore();
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final album = page.items[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
onTap: () => ctx.push('/albums/${album.id}',
extra: album),
);
},
),
);
}),
);
},
);
}
}
@@ -260,10 +406,14 @@ class _LikedTab extends ConsumerWidget {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: ar.items.length,
itemBuilder: (ctx, i) => ArtistCard(
artist: ar.items[i],
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
),
itemBuilder: (ctx, i) {
final artist = ar.items[i];
return ArtistCard(
artist: artist,
onTap: () =>
ctx.push('/artists/${artist.id}', extra: artist),
);
},
),
),
],
@@ -275,10 +425,14 @@ class _LikedTab extends ConsumerWidget {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: al.items.length,
itemBuilder: (ctx, i) => AlbumCard(
album: al.items[i],
onTap: () => ctx.push('/albums/${al.items[i].id}'),
),
itemBuilder: (ctx, i) {
final album = al.items[i];
return AlbumCard(
album: album,
onTap: () =>
ctx.push('/albums/${album.id}', extra: album),
);
},
),
),
],
+175 -34
View File
@@ -12,12 +12,27 @@ import 'album_cover_cache.dart';
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
MinstrelAudioHandler() {
_player.playbackEventStream.listen(_broadcastState);
_player.playbackEventStream.listen(
_broadcastState,
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
// failure, network drop) here. Without an error sink, the
// player just goes quiet — exactly the "starts then stops"
// symptom we hit.
onError: (Object e, StackTrace st) {
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
},
);
_player.currentIndexStream.listen(_onCurrentIndexChanged);
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
// shuffleMode + repeatMode fields stay current for UI subscribers.
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
_player.loopModeStream.listen((_) => _broadcastState(null));
// Watch buffered-position so we can register stream-cached files
// in the audio cache index once they're fully downloaded. Without
// this, files written by LockCachingAudioSource never appear in
// the index and the eviction loop can't reclaim them.
_player.bufferedPositionStream
.listen((_) => unawaited(_maybeRegisterStreamCache()));
}
final AudioPlayer _player = AudioPlayer();
@@ -26,6 +41,33 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
AlbumCoverCache? _coverCache;
AudioCacheManager? _audioCacheManager;
/// Trackers to dedupe registration — once we've inserted an index row
/// for a trackId, don't repeat the work on every buffered-position
/// emit. Cleared on dispose only; surviving across queue rebuilds is
/// fine because the index is itself the source of truth.
final Set<String> _streamCacheRegistered = {};
/// Cached on first use so we don't hit the platform channel every
/// time the buffered-position stream emits (~200ms cadence).
String? _cacheDirPath;
/// True while _fillRemainingSources is doing backward-fill inserts
/// at index 0..initialIndex-1. Each insert shifts the player's
/// currentIndex (it tracks the actively-playing source through
/// list mutations), and the resulting _onCurrentIndexChanged
/// callbacks would push the wrong MediaItem onto the stream
/// (queue.value[shifted_idx] != actively-playing track). When
/// the fill completes, currentIndex == initialIndex, mediaItem
/// is already correct, and we re-enable normal listener behavior.
bool _suppressIndexUpdates = false;
/// Increments on every setQueueFromTracks call. The background
/// fill task captures the value at start and bails if the live
/// counter has moved on — without this, a stale fill will keep
/// addAudioSource'ing tracks from the previous playlist into the
/// new queue, leaving the player "locked" to a corrupted state.
int _queueGeneration = 0;
/// Volume stream for UI subscribers. Mirrors the just_audio player's
/// volume directly; set via setVolume(double).
Stream<double> get volumeStream => _player.volumeStream;
@@ -49,32 +91,86 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
_token = token;
if (coverCache != null) _coverCache = coverCache;
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
'tokenPresent=${token != null && token.isNotEmpty} '
'coverCachePresent=${_coverCache != null} '
'audioCachePresent=${_audioCacheManager != null}');
}
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
if (tracks.isEmpty) return;
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
// Bump the generation FIRST. Any in-flight _fillRemainingSources
// from a previous play will see the mismatch on its next gen
// check and stop calling player mutations — important so a stale
// fill doesn't append old-playlist tracks into the new queue.
final myGen = ++_queueGeneration;
// Reset suppress flag in case a prior backward-fill bailed on
// gen check before reaching its `finally`.
_suppressIndexUpdates = false;
// Populate the visible queue + current mediaItem immediately so
// the player UI reflects the user's tap before any source has
// been built. Source list at the just_audio layer fills in
// asynchronously below.
final items = tracks.map(_toMediaItem).toList();
queue.add(items);
if (items.isNotEmpty) {
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
mediaItem.add(items[clampedInitial]);
// Fast path: build only the initial source so the player can
// start. Remaining sources stream in via _fillRemainingSources()
// in the background — addAudioSource for next/auto-advance
// tracks, insertAudioSource for skipPrev tracks.
final initial = await _buildAudioSource(tracks[clampedInitial]);
if (myGen != _queueGeneration) {
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
return;
}
debugPrint('audio_handler.setQueueFromTracks: '
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
final sources = await Future.wait(tracks.map(_buildAudioSource));
await _player.setAudioSources([initial], initialIndex: 0);
await _player.setAudioSources(
sources,
initialIndex: initialIndex,
);
// Kick the cover fetch for the initial item — async, doesn't block
// playback. Subsequent track changes are handled by the
// currentIndexStream listener.
unawaited(_loadArtForCurrentItem());
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
}
/// Background fill of the rest of the just_audio source list after
/// the initial source is playing. Forward direction first (most
/// common skipNext target). Backward inserts shift the player's
/// currentIndex; we suppress _onCurrentIndexChanged side effects
/// for those so the mediaItem stream doesn't bounce to the wrong
/// queue entry.
///
/// `gen` is the queue generation captured when this fill started.
/// Every loop iteration checks against _queueGeneration; if a
/// newer setQueueFromTracks has run (user tapped play on something
/// else), bail immediately so we don't pollute the new queue with
/// addAudioSource calls from this stale fill.
Future<void> _fillRemainingSources(
List<TrackRef> tracks, int initialIndex, int gen) async {
for (var i = initialIndex + 1; i < tracks.length; i++) {
if (gen != _queueGeneration) return;
try {
final src = await _buildAudioSource(tracks[i]);
if (gen != _queueGeneration) return;
await _player.addAudioSource(src);
} catch (e) {
debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
}
}
if (initialIndex > 0) {
_suppressIndexUpdates = true;
try {
for (var i = 0; i < initialIndex; i++) {
if (gen != _queueGeneration) return;
final src = await _buildAudioSource(tracks[i]);
if (gen != _queueGeneration) return;
await _player.insertAudioSource(i, src);
}
} catch (e) {
debugPrint('audio_handler: backward fill failed: $e');
} finally {
// Only release the flag if we're still the active gen — a
// newer setQueueFromTracks already reset it for itself.
if (gen == _queueGeneration) _suppressIndexUpdates = false;
}
}
}
String _resolveStreamUrl(TrackRef t) {
@@ -103,7 +199,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
if (mgr != null) {
final path = await mgr.pathFor(t.id);
if (path != null) {
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
return AudioSource.uri(Uri.file(path));
}
}
@@ -127,17 +222,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
// pin / Download buttons cover the index path; LockCaching handles
// the network optimization.
if (mgr != null) {
final cacheDir = await getApplicationCacheDirectory();
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
'using LockCachingAudioSource → ${cacheFile.path}');
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
// ignore: experimental_member_use
return LockCachingAudioSource(parsed,
headers: headers, cacheFile: cacheFile);
}
// 3. No manager configured: plain network source (legacy path).
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
return AudioSource.uri(parsed, headers: headers);
}
@@ -165,19 +257,68 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
queue.add([...queue.value, item]);
}
MediaItem _toMediaItem(TrackRef t) => MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
// Stash album_id in extras so _loadArtForCurrentItem can pull it
// back without re-walking the track list.
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
);
MediaItem _toMediaItem(TrackRef t) {
// Stash album_id + artist_id in extras so widgets reconstructing
// a TrackRef from the MediaItem (player kebab → "Go to artist",
// "Go to album") have the IDs they need to navigate. Earlier code
// only carried album_id which left "Go to artist" pushing
// /artists/ (empty id, route 404).
final extras = <String, dynamic>{};
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
return MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
extras: extras.isEmpty ? null : extras,
);
}
/// Once a track is fully buffered (LockCaching has written the whole
/// file to disk), insert an audio_cache_index row so the file shows
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
/// cache manager isn't configured, no current track, the file isn't
/// fully buffered yet, the on-disk file is missing, or we already
/// registered this trackId during this subscription.
Future<void> _maybeRegisterStreamCache() async {
final mgr = _audioCacheManager;
if (mgr == null) return;
final current = mediaItem.value;
if (current == null) return;
final trackId = current.id;
if (_streamCacheRegistered.contains(trackId)) return;
final dur = _player.duration;
if (dur == null) return;
final buf = _player.bufferedPosition;
// 200ms slack for header bytes / encoding rounding.
if (buf < dur - const Duration(milliseconds: 200)) return;
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
final file = File(path);
if (!await file.exists()) return;
final size = await file.length();
if (size <= 0) return;
_streamCacheRegistered.add(trackId);
await mgr.registerStreamCache(trackId, path, size);
}
void _onCurrentIndexChanged(int? idx) {
if (idx == null) return;
if (_suppressIndexUpdates) return;
// Push the new track's MediaItem onto the mediaItem stream so
// the player UI rebuilds with the new title/artist/album/cover.
// Without this, the bar and full player stayed pinned to whichever
// track was passed via setQueueFromTracks(initialIndex:) regardless
// of skip/auto-advance.
final items = queue.value;
if (idx >= 0 && idx < items.length) {
mediaItem.add(items[idx]);
}
unawaited(_loadArtForCurrentItem());
}
@@ -113,20 +113,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 32),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
const SizedBox(height: 24),
_PrimaryControls(
fs: fs,
ref: ref,
isPlaying: isPlaying,
),
const SizedBox(height: 24),
_SecondaryControls(
fs: fs,
actions: actions,
shuffleOn: shuffleOn,
repeatMode: repeatMode,
media: media,
),
const SizedBox(height: 8),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
const SizedBox(height: 24),
_PrimaryControls(
fs: fs,
ref: ref,
isPlaying: isPlaying,
),
const SizedBox(height: 24),
],
@@ -219,30 +220,15 @@ class _TitleRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(children: [
Expanded(
child: Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
),
]);
// Title-only — like + kebab moved into _SecondaryControls above
// the seek bar so they share the same row as shuffle/repeat/queue.
return Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
);
}
}
@@ -346,17 +332,23 @@ class _PrimaryControls extends StatelessWidget {
}
}
/// Action row sitting just above the seek bar. Holds shuffle / repeat
/// / queue plus the like + kebab that used to live in the title row,
/// so the title can sit truly centered above and this row carries
/// every per-track action in one place.
class _SecondaryControls extends StatelessWidget {
const _SecondaryControls({
required this.fs,
required this.actions,
required this.shuffleOn,
required this.repeatMode,
required this.media,
});
final FabledSwordTheme fs;
final PlayerActions actions;
final bool shuffleOn;
final AudioServiceRepeatMode repeatMode;
final MediaItem media;
@override
Widget build(BuildContext context) {
@@ -392,6 +384,31 @@ class _SecondaryControls extends StatelessWidget {
icon: Icon(Icons.queue_music, color: fs.ash),
onPressed: () => GoRouter.of(context).push('/queue'),
),
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
// /now-playing lives outside the ShellRoute. Pushing
// /artists/:id or /albums/:id (both shell-children) on top
// of it would make go_router try to mount a duplicate
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
// we hit before with /queue). Await our own pop fully
// before pushing the destination so go_router never sees
// both pages active in the same frame.
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
},
),
],
);
}
+3 -1
View File
@@ -286,7 +286,9 @@ TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '',
// artist_id is stashed in extras by audio_handler — without it
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
artistId: (media.extras?['artist_id'] as String?) ?? '',
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/radio.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
@@ -102,6 +103,15 @@ class PlayerActions {
Future<void> setVolume(double v) async {
await _ref.read(audioHandlerProvider).setVolume(v);
}
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
/// returned track list (seed at index 0 + recommended picks).
Future<void> startRadio(String trackId) async {
final dio = await _ref.read(dioProvider.future);
final tracks = await RadioApi(dio).seedTrack(trackId);
if (tracks.isEmpty) return;
await playTracks(tracks);
}
}
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
@@ -12,13 +12,26 @@ import '../theme/theme_extension.dart';
import 'playlists_provider.dart';
class PlaylistDetailScreen extends ConsumerWidget {
const PlaylistDetailScreen({required this.id, super.key});
const PlaylistDetailScreen({required this.id, this.seed, super.key});
final String id;
/// Optional Playlist passed via go_router extra so the header
/// (title, cover, track count, play/download CTAs) can render
/// before the full detail fetch resolves. Same pattern as album
/// + artist nav hydration.
final Playlist? seed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final detail = ref.watch(playlistDetailProvider(id));
// Resolve the best playlist info available for the AppBar title.
final livePlaylist = detail.value?.playlist;
final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty)
? livePlaylist.name
: (seed?.name ?? '');
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
@@ -28,17 +41,21 @@ class PlaylistDetailScreen extends ConsumerWidget {
icon: Icon(Icons.arrow_back, color: fs.parchment),
onPressed: () => context.pop(),
),
title: detail.maybeWhen(
data: (d) => Text(
d.playlist.name,
style: TextStyle(color: fs.parchment),
overflow: TextOverflow.ellipsis,
),
orElse: () => const SizedBox.shrink(),
),
title: headerName.isEmpty
? const SizedBox.shrink()
: Text(
headerName,
style: TextStyle(color: fs.parchment),
overflow: TextOverflow.ellipsis,
),
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
// Loading from a seed: render the header immediately + a
// small inline "loading tracks" hint, instead of an opaque
// full-screen spinner. The body fills in when tracks arrive.
loading: () => seed == null
? const Center(child: CircularProgressIndicator())
: _SeedBody(playlist: seed!),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d),
@@ -47,6 +64,43 @@ class PlaylistDetailScreen extends ConsumerWidget {
}
}
/// Loading-state body: renders just the header from the seed
/// playlist + a "loading tracks…" placeholder. CTA buttons are
/// hidden until the real track list arrives (they need playable
/// refs to do anything useful).
class _SeedBody extends StatelessWidget {
const _SeedBody({required this.playlist});
final Playlist playlist;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListView(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (playlist.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
playlist.description,
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Text(
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
),
]);
}
}
class _Body extends ConsumerWidget {
const _Body({required this.detail});
final PlaylistDetail detail;
@@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget {
final p = items[i];
return _PlaylistTile(
playlist: p,
onTap: () => ctx.push('/playlists/${p.id}'),
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
);
},
),
@@ -83,7 +83,11 @@ class _PlaylistTile extends StatelessWidget {
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
: ServerImage(
url: playlist.coverUrl,
fit: BoxFit.cover,
fallback: Icon(Icons.queue_music, color: fs.ash),
),
),
),
const SizedBox(width: 12),
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -37,12 +40,21 @@ final playlistsListProvider =
fetchAndPopulate: () async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.list(kind: kind);
final ownedSysCount =
fresh.owned.where((p) => p.systemVariant != null).length;
debugPrint(
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
'(system=$ownedSysCount) public=${fresh.public.length}');
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
// every rebuild, so insertOrReplace alone leaves stale rows in
// drift. Tapping one of those stale tiles 404s. Delete every
// owned drift row whose id isn't in the fresh response, then
// upsert the fresh set.
final freshOwnedIds =
fresh.owned.map((p) => p.id).toSet();
await db.batch((b) {
if (user != null) {
b.deleteWhere(db.cachedPlaylists, (t) {
return t.userId.equals(user.id) &
t.id.isNotIn(freshOwnedIds);
});
}
for (final p in fresh.all) {
b.insert(db.cachedPlaylists, p.toDrift(),
mode: drift.InsertMode.insertOrReplace);
@@ -64,13 +76,6 @@ final playlistsListProvider =
.where((r) => r.userId != user.id && r.isPublic)
.map((r) => r.toRef())
.toList();
final ownedSys = owned.where((p) => p.isSystem).length;
final driftSys = filtered.where((r) => r.systemVariant != null).length;
debugPrint(
'playlistsListProvider($kind): drift rows=${rows.length} '
'filtered=${filtered.length} (system=$driftSys) '
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
'userId=${user.id}');
return PlaylistsList(owned: owned, public: pub);
},
isOnline: () async => (await ref
@@ -123,41 +128,180 @@ final playlistDetailProvider =
tracks: const [],
);
Future<bool> isOnline() async {
try {
return await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (_) {
return true;
}
}
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
// Collect the track + artist + album rows referenced by these
// playlist entries. Without writing the cachedTracks rows
// themselves, the detail-screen JOIN against cachedTracks
// returns null on every row (only the playlist_tracks join
// succeeds), so the UI shows a list with empty titles and
// unplayable tracks.
final tracks = <String, _TrackRefRow>{};
final artists = <String, ArtistRefRow>{};
final albums = <String, AlbumRefRow>{};
for (final t in fresh.tracks) {
final tId = t.trackId;
if (tId != null && tId.isNotEmpty) {
tracks.putIfAbsent(
tId,
() => _TrackRefRow(
id: tId,
title: t.title,
albumId: t.albumId ?? '',
artistId: t.artistId ?? '',
durationSec: t.durationSec,
),
);
}
final aId = t.artistId;
final aName = t.artistName;
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
}
final lbId = t.albumId;
final lbTitle = t.albumTitle;
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
}
}
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
// Wipe + re-insert this playlist's track positions so deletions
// propagate. Without the wipe, removed tracks would linger in
// drift after the playlist mutated server-side.
b.deleteWhere(
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists,
artists.values
.map((a) => CachedArtistsCompanion.insert(
id: a.id,
name: a.name,
sortName: a.name,
))
.toList(),
);
}
if (tracks.isNotEmpty) {
// Insert/update cachedTracks so the detail screen's JOIN
// produces real titles + durations. We don't have
// track_number / disc_number on the wire (PlaylistTrack
// omits them), so they default to 0 — UI doesn't surface
// them on this screen so it's fine.
b.insertAllOnConflictUpdate(
db.cachedTracks,
tracks.values
.map((t) => CachedTracksCompanion.insert(
id: t.id,
albumId: t.albumId,
artistId: t.artistId,
title: t.title,
durationMs: drift.Value(t.durationSec * 1000),
))
.toList(),
);
}
if (albums.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
albums.values
.map((a) => CachedAlbumsCompanion.insert(
id: a.id,
artistId: a.artistId,
title: a.title,
sortTitle: a.title,
))
.toList(),
);
}
});
return true;
} catch (e, st) {
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
// tiles can outlive the actual server-side playlist). Wipe the
// stale row + its track positions so the home tile disappears
// on next render and we don't keep trying.
if (e is DioException && e.response?.statusCode == 404) {
await db.batch((b) {
b.deleteWhere(
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
});
return false;
}
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
return false;
}
}
// Once-per-subscription guard so an empty server response doesn't
// cause repeated re-fetches.
var fetchAttempted = false;
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id);
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
});
// watch() re-emits next iteration with populated row.
} catch (_) {
yield emptyDetail();
}
} else {
if (fetchAttempted) {
yield emptyDetail();
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield emptyDetail();
continue;
}
final ok = await fetchAndPopulate();
if (!ok) yield emptyDetail();
continue;
}
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
// Same pattern as albumProvider: playlist row exists but no tracks
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
// tracks for system playlists). Trigger the same fetch, drift
// watch re-emits with populated tracks.
if (trackRows.isEmpty && !fetchAttempted) {
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
if (ok) continue; // wait for watch re-emit
}
}
final tracks = trackRows.asMap().entries.map((e) {
final r = e.value;
final track = r.readTableOrNull(db.cachedTracks);
@@ -177,9 +321,47 @@ final playlistDetailProvider =
}).toList();
yield PlaylistDetail(playlist: playlist, tracks: tracks);
// No SWR refresh here. The aggregate playlistsListProvider does
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
// refresh on every visit was multiplying with the prefetcher's
// parallel fetches and starving user-initiated playback. Pull-
// to-refresh on the detail page invalidates the provider, which
// is the right path for an explicit refresh.
}
});
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
/// and album rows referenced by playlist tracks before we batch-write
/// them to drift.
class ArtistRefRow {
ArtistRefRow(this.id, this.name);
final String id;
final String name;
}
class AlbumRefRow {
AlbumRefRow(this.id, this.title, this.artistId);
final String id;
final String title;
final String artistId;
}
class _TrackRefRow {
_TrackRefRow({
required this.id,
required this.title,
required this.albumId,
required this.artistId,
required this.durationSec,
});
final String id;
final String title;
final String albumId;
final String artistId;
final int durationSec;
}
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
@@ -21,7 +21,8 @@ class PlaylistCard extends StatelessWidget {
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => context.push('/playlists/${playlist.id}'),
onTap: () =>
context.push('/playlists/${playlist.id}', extra: playlist),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@@ -31,9 +32,19 @@ class PlaylistCard extends StatelessWidget {
width: 144,
height: 144,
color: fs.slate,
// coverUrl is deterministic per playlist (see
// CachedPlaylistAdapter.toRef). When the server's
// collage isn't built yet, ServerImage's
// errorBuilder shows the queue_music icon over the
// slate background.
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
: ServerImage(
url: playlist.coverUrl,
fit: BoxFit.cover,
fallback:
Icon(Icons.queue_music, color: fs.ash, size: 56),
),
),
),
const SizedBox(height: 8),
+25 -4
View File
@@ -7,6 +7,9 @@ import '../auth/login_screen.dart';
import '../auth/server_url_screen.dart';
import '../library/album_detail_screen.dart';
import '../library/artist_detail_screen.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/playlist.dart';
import '../discover/discover_screen.dart';
import '../library/home_screen.dart';
import '../library/library_screen.dart';
@@ -83,19 +86,34 @@ GoRouter buildRouter(Ref ref) {
},
),
),
// /queue lives outside the ShellRoute too. Why: pushing /queue
// from /now-playing (which is also outside the shell) used to
// cause go_router to mount a second ShellRoute instance under
// the existing one, producing a duplicate page-key assertion
// (NavigatorState._debugCheckDuplicatedPageKeys). Top-level
// routes can stack on each other freely; shell-children can't
// when something on top of the shell is already routing.
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
ShellRoute(
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(
path: '/artists/:id',
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
// `extra` carries an optional ArtistRef so the detail
// header renders immediately while tracks/albums load.
builder: (_, s) => ArtistDetailScreen(
id: s.pathParameters['id']!,
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
),
),
GoRoute(
path: '/albums/:id',
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
builder: (_, s) => AlbumDetailScreen(
id: s.pathParameters['id']!,
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
),
),
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
@@ -103,7 +121,10 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
GoRoute(
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
builder: (_, s) => PlaylistDetailScreen(
id: s.pathParameters['id']!,
seed: s.extra is Playlist ? s.extra as Playlist : null,
),
),
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
@@ -11,6 +11,7 @@ class TrackActionsButton extends StatelessWidget {
super.key,
required this.track,
this.hideQueueActions = false,
this.onNavigate,
});
final TrackRef track;
@@ -19,6 +20,11 @@ class TrackActionsButton extends StatelessWidget {
/// screen where the menu's track IS the currently-playing one.
final bool hideQueueActions;
/// Forwarded to TrackActionsSheet.onNavigate. The host receives the
/// destination path AFTER the sheet has popped and is responsible
/// for navigating to it (typically: pop self first, then push).
final Future<void> Function(String path)? onNavigate;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -29,6 +35,7 @@ class TrackActionsButton extends StatelessWidget {
context,
track,
hideQueueActions: hideQueueActions,
onNavigate: onNavigate,
),
);
}
@@ -21,15 +21,27 @@ class TrackActionsSheet extends ConsumerWidget {
super.key,
required this.track,
required this.hideQueueActions,
this.onNavigate,
});
final TrackRef track;
final bool hideQueueActions;
/// Optional hook for "Go to album" / "Go to artist". Called with
/// the destination route path AFTER the sheet has popped. Lets
/// callers that live OUTSIDE the ShellRoute (e.g. the full player
/// at /now-playing) pop themselves first then push, so go_router
/// doesn't try to mount a second ShellRoute on top of the active
/// top-level route. When null, the sheet does its own
/// context.push, which is correct for surfaces already inside the
/// shell (mini player, track rows on detail screens, etc.).
final Future<void> Function(String path)? onNavigate;
static Future<void> show(
BuildContext context,
TrackRef track, {
bool hideQueueActions = false,
Future<void> Function(String path)? onNavigate,
}) {
return showModalBottomSheet<void>(
context: context,
@@ -37,6 +49,7 @@ class TrackActionsSheet extends ConsumerWidget {
builder: (_) => TrackActionsSheet(
track: track,
hideQueueActions: hideQueueActions,
onNavigate: onNavigate,
),
);
}
@@ -117,6 +130,23 @@ class TrackActionsSheet extends ConsumerWidget {
}
},
),
_MenuItem(
key: const Key('track_actions_start_radio'),
icon: Icons.radio,
label: 'Start radio',
onTap: () async {
Navigator.pop(context);
try {
await ref.read(playerActionsProvider).startRadio(track.id);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Couldn't start radio: $e")),
);
}
}
},
),
const _Divider(),
_MenuItem(
key: const Key('track_actions_go_to_album'),
@@ -124,7 +154,12 @@ class TrackActionsSheet extends ConsumerWidget {
label: 'Go to album',
onTap: () {
Navigator.pop(context);
context.push('/albums/${track.albumId}');
final path = '/albums/${track.albumId}';
if (onNavigate != null) {
onNavigate!(path);
} else {
context.push(path);
}
},
),
_MenuItem(
@@ -133,7 +168,12 @@ class TrackActionsSheet extends ConsumerWidget {
label: 'Go to artist',
onTap: () {
Navigator.pop(context);
context.push('/artists/${track.artistId}');
final path = '/artists/${track.artistId}';
if (onNavigate != null) {
onNavigate!(path);
} else {
context.push(path);
}
},
),
const _Divider(),
+27 -20
View File
@@ -40,19 +40,28 @@ func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
// tracks (ordered by disc/track number via the underlying query) with
// duration summed from the track list — keeps one source of truth.
//
// Two DB round trips: the album+artist join and the per-user tracks
// list. Down from three (separate album, artist, tracks) before
// GetAlbumWithArtist landed.
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
if apiErr != nil {
writeErr(w, apiErr)
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
row, err := q.GetAlbumWithArtist(r.Context(), id)
if err != nil {
h.logger.Error("api: get album artist failed", "err", err)
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("album"))
return
}
h.logger.Error("api: get album+artist failed", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
album := row.Album
artistName := row.ArtistName
var userID pgtype.UUID
if user, ok := auth.UserFromContext(r.Context()); ok {
userID = user.ID
@@ -68,20 +77,24 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
refs := make([]TrackRef, 0, len(tracks))
durSec := 0
for _, t := range tracks {
ref := trackRefFrom(t, album.Title, artist.Name)
ref := trackRefFrom(t, album.Title, artistName)
refs = append(refs, ref)
durSec += ref.DurationSec
}
detail := AlbumDetail{
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
Tracks: refs,
}
writeJSON(w, http.StatusOK, detail)
}
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
// each album carries its own track_count (one count query per album, same
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
// each album carries its own track_count.
//
// Down from 1 + 1 + N queries (artist, albums, per-album CountTracksByAlbum)
// to 1 + 1 (artist, albums-with-track-count via correlated subquery).
// On a 30-album artist that's ~32 round trips collapsed to 2 — the
// difference between "feels slow" and "feels instant" on detail nav.
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
@@ -89,25 +102,19 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
writeErr(w, apiErr)
return
}
albums, err := q.ListAlbumsByArtist(r.Context(), artist.ID)
rows, err := q.ListAlbumsByArtistWithTrackCount(r.Context(), artist.ID)
if err != nil {
h.logger.Error("api: list albums by artist failed", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
refs := make([]AlbumRef, 0, len(albums))
for _, a := range albums {
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
if cerr != nil {
h.logger.Error("api: count tracks failed", "err", cerr)
writeErr(w, apierror.InternalMsg("lookup failed", cerr))
return
}
refs := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
// durationSec=0: not aggregated for nested album lists per spec data flow.
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
}
detail := ArtistDetail{
ArtistRef: artistRefFrom(artist, len(albums)),
ArtistRef: artistRefFrom(artist, len(rows)),
Albums: refs,
}
writeJSON(w, http.StatusOK, detail)
+12
View File
@@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", imageContentType(path))
// Cover bytes change rarely (cover-source enrichment, manual rescan,
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
// clients skip the conditional GET for the bulk of a session, but
// stale art clears within 24h after a re-scan. ServeContent below
// still emits Last-Modified for the conditional path when needed.
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
@@ -140,5 +146,11 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
w.Header().Set("Accept-Ranges", "bytes")
// Track bytes are immutable for a given track id (the scanner
// indexes by file_path; re-encoded files take new ids). One-year
// max-age + immutable lets the client cache (LockCachingAudioSource
// on the Flutter side, browser cache on web) skip even the
// conditional GET on repeat plays.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
+5
View File
@@ -386,6 +386,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
return
}
full := filepath.Join(h.dataDir, *detail.CoverPath)
// Playlist collages recompute when the playlist's tracks change
// (system playlists re-rendered on rebuild, user playlists when
// modified). 5 minutes is short enough for normal edits to feel
// fresh, long enough to skip repeat fetches during a session.
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
http.ServeFile(w, r, full)
}
+85
View File
@@ -168,6 +168,41 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
return i, err
}
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.id = $1
`
type GetAlbumWithArtistRow struct {
Album Album
ArtistName string
}
// Combined fetch for /api/albums/{id}: returns the album row + the
// joined artist name in a single round trip. Replaces a sequential
// GetAlbumByID + GetArtistByID pair on the hot detail-page path.
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
var i GetAlbumWithArtistRow
err := row.Scan(
&i.Album.ID,
&i.Album.Title,
&i.Album.SortTitle,
&i.Album.ArtistID,
&i.Album.ReleaseDate,
&i.Album.Mbid,
&i.Album.CoverArtPath,
&i.Album.CreatedAt,
&i.Album.UpdatedAt,
&i.Album.CoverArtSource,
&i.Album.CoverArtSourcesVersion,
&i.ArtistName,
)
return i, err
}
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
`
@@ -389,6 +424,56 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
return items, nil
}
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
AS track_count
FROM albums
WHERE albums.artist_id = $1
ORDER BY release_date NULLS LAST, sort_title
`
type ListAlbumsByArtistWithTrackCountRow struct {
Album Album
TrackCount int64
}
// Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
// per album). Returns each album joined with its track count via a
// correlated subquery — single round trip regardless of album count.
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsByArtistWithTrackCountRow
for rows.Next() {
var i ListAlbumsByArtistWithTrackCountRow
if err := rows.Scan(
&i.Album.ID,
&i.Album.Title,
&i.Album.SortTitle,
&i.Album.ArtistID,
&i.Album.ReleaseDate,
&i.Album.Mbid,
&i.Album.CoverArtPath,
&i.Album.CreatedAt,
&i.Album.UpdatedAt,
&i.Album.CoverArtSource,
&i.Album.CoverArtSourcesVersion,
&i.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
FROM albums
+41 -25
View File
@@ -12,17 +12,19 @@ import (
)
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count,
max_started.started_at::timestamptz AS last_played_at
FROM artists a
JOIN LATERAL (
SELECT max(pe.started_at) AS started_at
WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND t.artist_id = a.id
) max_started ON max_started.started_at IS NOT NULL
WHERE pe.user_id = $1
GROUP BY t.artist_id
)
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count,
up.last_started::timestamptz AS last_played_at
FROM user_plays up
JOIN artists a ON a.id = up.artist_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
@@ -32,7 +34,7 @@ LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = a.id
) cnt ON true
ORDER BY max_started.started_at DESC, a.id
ORDER BY up.last_started DESC, a.id
LIMIT $2
`
@@ -52,6 +54,15 @@ type ListLastPlayedArtistsForUserRow struct {
// a derived cover_album_id via a representative-album lateral join (most
// recent album that has cover_art_path set). album_count joined for the
// ArtistRef wire shape.
//
// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
// subquery per artist — O(total_artists) plan even for users with a
// handful of plays. New shape aggregates the user's plays by artist
// via the play_events → tracks join up front (uses
// play_events_user_track_idx + tracks pkey lookups), then attaches
// the artist row and lateral cover/count subqueries only for the
// artists that actually appear. Distinct-artists set is small for a
// typical user, so cost is bounded by play history not library size.
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
if err != nil {
@@ -87,24 +98,24 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
}
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
albums.title, artists.name
ORDER BY count(*) DESC, t.id
FROM plays p
JOIN tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
LIMIT $2
`
@@ -122,6 +133,11 @@ type ListMostPlayedTracksForUserRow struct {
// M6a: top-N tracks by completed-play count for the user. was_skipped
// excludes skips so a user spamming next doesn't fabricate a top track.
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
//
// Aggregate play_events first (uses play_events_user_track_idx) and
// join through tracks/albums/artists only for the survivors. Earlier
// shape did the join across every play_event row before grouping —
// O(plays) instead of O(distinct tracks).
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
if err != nil {
+20
View File
@@ -14,6 +14,26 @@ RETURNING *;
-- name: GetAlbumByID :one
SELECT * FROM albums WHERE id = $1;
-- name: GetAlbumWithArtist :one
-- Combined fetch for /api/albums/{id}: returns the album row + the
-- joined artist name in a single round trip. Replaces a sequential
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.id = $1;
-- name: ListAlbumsByArtistWithTrackCount :many
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
-- per album). Returns each album joined with its track count via a
-- correlated subquery — single round trip regardless of album count.
SELECT sqlc.embed(albums),
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
AS track_count
FROM albums
WHERE albums.artist_id = $1
ORDER BY release_date NULLS LAST, sort_title;
-- name: GetAlbumByArtistAndTitle :one
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
+41 -25
View File
@@ -206,24 +206,29 @@ LIMIT $3;
-- M6a: top-N tracks by completed-play count for the user. was_skipped
-- excludes skips so a user spamming next doesn't fabricate a top track.
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
--
-- Aggregate play_events first (uses play_events_user_track_idx) and
-- join through tracks/albums/artists only for the survivors. Earlier
-- shape did the join across every play_event row before grouping —
-- O(plays) instead of O(distinct tracks).
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT sqlc.embed(t),
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
albums.title, artists.name
ORDER BY count(*) DESC, t.id
FROM plays p
JOIN tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
LIMIT $2;
-- name: ListLastPlayedArtistsForUser :many
@@ -231,17 +236,28 @@ LIMIT $2;
-- a derived cover_album_id via a representative-album lateral join (most
-- recent album that has cover_art_path set). album_count joined for the
-- ArtistRef wire shape.
SELECT sqlc.embed(a),
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count,
max_started.started_at::timestamptz AS last_played_at
FROM artists a
JOIN LATERAL (
SELECT max(pe.started_at) AS started_at
--
-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
-- subquery per artist — O(total_artists) plan even for users with a
-- handful of plays. New shape aggregates the user's plays by artist
-- via the play_events → tracks join up front (uses
-- play_events_user_track_idx + tracks pkey lookups), then attaches
-- the artist row and lateral cover/count subqueries only for the
-- artists that actually appear. Distinct-artists set is small for a
-- typical user, so cost is bounded by play history not library size.
WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND t.artist_id = a.id
) max_started ON max_started.started_at IS NOT NULL
WHERE pe.user_id = $1
GROUP BY t.artist_id
)
SELECT sqlc.embed(a),
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count,
up.last_started::timestamptz AS last_played_at
FROM user_plays up
JOIN artists a ON a.id = up.artist_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
@@ -251,7 +267,7 @@ LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = a.id
) cnt ON true
ORDER BY max_started.started_at DESC, a.id
ORDER BY up.last_started DESC, a.id
LIMIT $2;
-- name: ListRediscoverAlbumsForUser :many
+15 -2
View File
@@ -39,6 +39,10 @@
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
);
const discoverPlaylist = $derived(
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
);
const songsLikePlaylists = $derived(
(systemPlaylistsQ.data?.owned ?? [])
.filter((p) => p.system_variant === 'songs_like_artist')
@@ -48,7 +52,7 @@
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
function placeholderVariant(slot: 'for-you' | 'songs-like'): PlaceholderVariant {
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
const s = systemStatusQ.data;
if (!s) return 'pending';
if (s.in_flight) return 'building';
@@ -71,7 +75,16 @@
out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
}
// Slots 2-4: Songs-like (real first, padded with placeholders).
// Slot 2: Discover (real or placeholder). Server emits this with
// system_variant='discover' once the recommendation engine has
// eligible tracks.
if (discoverPlaylist) {
out.push({ kind: 'real', playlist: discoverPlaylist });
} else {
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
}
// Slots 3-5: Songs-like (real first, padded with placeholders).
for (let i = 0; i < 3; i++) {
if (songsLikePlaylists[i]) {
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
+6 -3
View File
@@ -54,10 +54,11 @@ beforeEach(() => {
afterEach(() => vi.clearAllMocks());
describe('home Playlists section', () => {
test('renders 4 placeholder cards when no playlists exist', () => {
test('renders 5 placeholder cards when no playlists exist', () => {
// Slot layout: For-You, Discover, 3× Songs-like.
render(Page);
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
expect(placeholders).toHaveLength(4);
expect(placeholders).toHaveLength(5);
});
test('building status sets variant=building on placeholders', () => {
@@ -103,6 +104,8 @@ describe('home Playlists section', () => {
render(Page);
expect(screen.getByText('For You')).toBeInTheDocument();
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
expect(placeholders).toHaveLength(3); // 3 songs-like slots
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
// real For-You tile.
expect(placeholders).toHaveLength(4);
});
});