Compare commits

...

117 Commits

Author SHA1 Message Date
bvandeusen baa601765e Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main 2026-05-14 02:36:47 +00:00
bvandeusen 3db90020d4 chore(flutter): bump version to 2026.5.13+1 for release 2026-05-13 22:29:11 -04:00
bvandeusen 7367595e71 feat(flutter): cosmetic reveal animations (Slice F)
Final slice of the per-item rendering pass. Wraps every tile widget
in an AnimatedSwitcher between the skeleton placeholder and the real
card. 220ms cross-fade with easeOut: tiles "settle into place"
rather than hard-cutting from shimmer to content. Since each tile
fades independently as its data lands — and the HydrationQueue's
concurrency cap drains in a natural cascade — the overall feel is
the staged "page builds piece by piece" effect we wanted, with no
per-tile position math required.

Bumps CachedNetworkImage fadeInDuration from zero to 120ms (server_
image.dart, discover_screen.dart). Imperceptible on cache hits since
the image decodes synchronously; on cache misses the bytes fade in
smoothly instead of popping. Slice 1's "zero fade" call was right
about the 500ms default being a regression, but 120ms threads the
needle.

Playlist detail wraps its body in the same AnimatedSwitcher so the
cold-load skeleton page cross-fades into the real track list.

Tiles affected: home _AlbumTile / _ArtistTile / _TrackTile + liked
_LikedAlbumTile / _LikedArtistTile / _LikedTrackRow + playlist
_SkeletonBody / _Body. All keyed via ValueKey so AnimatedSwitcher
detects the transition.

End of the per-item pass. Net behavior: cold visits paint shaped
pages instantly with skeletons, content cascades in as hydration
lands; warm visits paint fully from drift in the first frame.
2026-05-13 22:08:34 -04:00
bvandeusen 158a5d7506 fix(flutter): drop unused adapters import in library_screen
Slice E removed the bulk fetchAndPopulate paths that called toRef /
toDrift; the adapters.dart import is now unused.
2026-05-13 21:59:35 -04:00
bvandeusen f2fa441405 feat(flutter): liked tabs on per-item rendering (Slice E)
The three liked-tab providers now yield ordered lists of entity IDs
(read from cached_likes ORDER BY likedAt DESC). The UI renders
per-tile widgets that hydrate each entity individually via
albumTileProvider / artistTileProvider / trackTileProvider.

fetchAndPopulate dropped from the per-provider bulk endpoints to a
single shared call against /api/likes/ids — much cheaper, and the
tile providers handle entity hydration themselves. The bulk
/api/likes/{tracks,albums,artists} endpoints are no longer in the
Flutter cold path (server keeps serving them for web compat).

Cross-device SSE invalidate paths preserved so cross-device likes
still feel instant. Local LikesController mutations propagate via
cached_likes optimistic writes — same drift watch() route as before.

Tap-to-play on a track row uses currently-hydrated TrackRefs as the
play queue; still-loading tracks are skipped and join on next
rebuild as hydrations land.
2026-05-13 21:55:20 -04:00
bvandeusen a324454efe feat(flutter): skeleton rows during playlist cold-load (Slice D)
Cold-visit playlist detail used to render the header from the seed
and then a single CircularProgressIndicator while the bulk fetch
ran. Now it renders the header + seed.trackCount worth of skeleton
rows (capped at 8 when no seed is present). The real list swaps in
without a layout jump.

Slice D was originally scoped for per-track hydration through a new
discovery endpoint, but the unavailable-entries data model (playlist
rows that lost their underlying track to deletion / quarantine) make
that disproportionately expensive — would require a schema change to
support null trackIds on cached_playlist_tracks, a server endpoint,
and a screen rewrite. The skeleton-row approach captures ~80% of the
perceptual win at a fraction of the cost. True per-track hydration
remains a future opportunity if cold visits to very large playlists
still feel sluggish after Slice F polish lands.
2026-05-13 21:47:13 -04:00
bvandeusen 03c13d21c6 feat(flutter): home screen on per-item rendering (Slice C)
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.

Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.

The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
  smaller than /api/home), then sections appear shaped with
  skeleton tiles; each tile materializes as the hydration queue
  drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
  instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
  real cards next to skeleton cards. Layout doesn't shift because
  skeletons match real-card dimensions exactly.

CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.

Old /api/home endpoint untouched, so the web client keeps working
unchanged.
2026-05-13 21:05:43 -04:00
bvandeusen 0119eacf14 feat(api): GET /api/home/index for per-item rendering (Slice B)
Sibling to /api/home that returns the same five sections (recently
added, rediscover albums, rediscover artists, most played, last
played) but as flat slices of entity ID strings instead of
denormalized objects. The Flutter client uses this to drive its
per-item rendering pass — small discovery response then per-tile
hydration via the existing /api/albums/:id, /api/artists/:id,
/api/tracks/:id endpoints.

Reuses recommendation.HomeData so the DB cost is identical to
/api/home. JSON payload shrinks roughly an order of magnitude on
populated libraries (no embedded title / artist / cover URL fields).

Old /api/home stays untouched so the web client and older Flutter
builds keep working — no min-client-version bump needed until both
clients have migrated.
2026-05-13 20:41:44 -04:00
bvandeusen 0504cae27c fix(flutter): drop unused imports flagged by analyze
Slice A landed with three transitive imports that the analyzer
correctly flagged as unused. AppDb / CachedAlbums table refs
propagate through audio_cache_manager.dart's `show appDbProvider`
re-export chain so the explicit db.dart import in the consumers
is redundant.
2026-05-13 20:30:33 -04:00
bvandeusen 64db364834 feat(flutter): per-item rendering infrastructure (Slice A)
Plumbing for the per-item rendering pass — no UI changes yet, just
the layers the home/playlist/liked migrations will sit on.

* CachedHomeIndex drift table (schema 5→6) — section/position →
  entity-id rows, populated by the upcoming /api/home/index endpoint.
* HydrationQueue (concurrency=4, in-flight dedup) — bounded request
  pump that takes (entityType, entityId) and persists the result to
  the right cached_<entity> table. Albums + artists wired today;
  tracks deferred until /api/tracks/:id exists.
* Per-entity tile providers (albumTileProvider, artistTileProvider,
  trackTileProvider as StreamProvider.family) — watch drift, enqueue
  hydration on miss, yield AsyncValue<EntityRef?>.
* Skeleton widgets (album/artist/track) matched to the real card
  dimensions with a 1.2s shimmer sweep using FabledSword tokens. No
  shimmer-package dep — single AnimationController per surface.

See docs/superpowers/specs/2026-05-13-per-item-rendering-design.md
for the full architecture rationale.
2026-05-13 20:25:40 -04:00
bvandeusen 99462185b4 feat(flutter): drift-first MyQuarantine
Final slice of the smooth-loading pass. Adds CachedQuarantineMine
(schema 5, columnar so flag/unflag can do row-level mutation) and
rewires MyQuarantineController to read from drift via watch() + SWR
refresh; flag/unflag write drift first and roll back on REST failure.

Public API (.flag / .unflag / .isHidden) unchanged so existing call
sites (library_screen Hidden tab, TrackActionsSheet) keep working.

Tests updated to match: bypassed-build-via-_StubController approach
no longer makes sense now that state lives in drift, so the suite is
rewritten against NativeDatabase.memory() with the same libsqlite3
skip the sync_controller suite uses on the CI runner.

The Hidden tab now paints from disk on cold open, the list is
queryable offline, and a flag from another device that arrived in
this user's quarantine via SSE-triggered invalidate lands the same
way as a local flag.
2026-05-13 18:41:18 -04:00
bvandeusen 395a6efb26 feat(flutter): drift-first History tab
Slice 4 of the smooth-loading pass. Adds CachedHistorySnapshot
(schema 4) and rewires _historyProvider through cacheFirst, mirroring
the homeProvider pattern: yield the last cached page immediately on
subscribe so the tab paints from disk on cold open, then SWR-refresh
in the background to surface fresh plays.

Also enables basic offline scrollback — the most recent History page
survives both app restart and connectivity loss.

JSON blob storage (vs columnar) because the page is small, always
read whole, and HistoryPage.fromJson already accepts the wire shape,
so server-side field additions don't force a migration.

History delta sync via library_changes is out of scope here; the next
visit's SWR pull is the source of freshness for now.
2026-05-13 18:31:36 -04:00
bvandeusen 1a2de0e738 feat(flutter): drift-first Liked tabs
Slice 3 of the smooth-loading pass. The three _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider entries on the Library
screen migrate from FutureProvider+REST to StreamProvider+cacheFirst.

Reads now flow from cached_likes joined against the metadata tables
SyncController already keeps fresh; LikesController's optimistic drift
write makes toggling a like re-emit these streams instantly without a
REST round-trip. Cold-cache fallback hits /api/likes/* when drift is
empty (fresh install pre-first-sync). SWR refresh on each visit catches
likes from other devices that haven't propagated via library_changes
yet.

The original FutureProvider versions fetched the first 50 rows. Drift
returns everything cached_likes knows about — for typical libraries
that's the full liked list. Pagination can come back when liked lists
are big enough to matter.

Like/unlike SSE invalidation paths preserved so cross-device updates
still feel real-time, even when the sender's library_changes hasn't
landed here yet.
2026-05-13 18:18:14 -04:00
bvandeusen 32c8d4f28f feat(flutter): pre-warm covers during library sync
Slice 2 of the cover-caching pass. SyncController now downloads cover
bytes for newly-upserted albums + playlists into the shared
flutter_cache_manager disk cache after each sync transaction commits.
A cold-start scroll through the home grid paints from disk on the very
first frame instead of firing one HTTP per visible tile.

Best-effort: fire-and-forget after commit, concurrency 3, per-URL
failures swallowed (404 for collages that haven't built yet, 401
during token-refresh races). Artist covers skipped — ArtistRef.coverUrl
is server-derived from "most-recent album" and not reconstructible
client-side; album pre-warm already covers the artist's primary visual.

Auth header reuses sessionTokenProvider for parity with ServerImage.
2026-05-13 18:06:53 -04:00
bvandeusen f732c49645 feat(flutter): disk-persistent cover cache via cached_network_image
Slice 1 of the cover-caching pass. The previous Image.network /
NetworkImage path only cached covers in memory, so a scroll-off + scroll-
back or an app restart re-downloaded every tile from the server. Swap
to cached_network_image so bytes land on disk (path_provider temp dir,
URL-keyed) and survive both.

Sites migrated:
  - ServerImage (all /api/*/cover usage — home grid, library, playlist,
    artist/album detail headers)
  - DiscoverScreen Lidarr suggestion thumbnails
  - PlayerBar mini cover (HTTPS branch; file:// branch unchanged since
    AlbumCoverCache files are already on disk)

Auth header forwarding preserved via httpHeaders. Fade-in disabled so
populated grids paint instantly on cache hit.

Slice 2 (pre-warm during sync) builds on this same cache manager.
2026-05-13 17:59:42 -04:00
bvandeusen ae5de91006 fix(flutter): tighten version-check cadence to 1m during active use
Drops the staleness gate from 1h to 1m and adds a Timer.periodic that
fires recheckIfStale every minute while the app is foregrounded. Net
effect: ~1 check per minute of active use, ~60 KB/hr data — trivially
affordable for the value of faster recovery when min_client_version
bumps server-side.

Timer is paired with the lifecycle observer: started in initState +
on resume, stopped in dispose + on pause/inactive/hidden/detached so
backgrounded apps don't burn battery on probes the user can't see.

Staleness gate still wraps the call so concurrent triggers (timer +
resume firing close together) dedupe to one network call. Manual
"Check now" still bypasses the gate via recheck().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:30:40 -04:00
bvandeusen 369ed800b9 fix(flutter): non-blocking version check + 1h staleness throttle
Cold-start spinner was up to ~38s on slow / remote connections
because VersionGate blocked the entire ShellRoute on /healthz, with
the default dio's 8s connect + 30s receive timeouts. The /healthz
server handler itself is fine (microsecond JSON encode); the blocker
was client-side. Three issues fixed in one pass:

1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget
   that always renders its child and just activates the version
   check controller on mount. The "you're too old" experience moves
   from a full-screen hard-block (_TooOldScreen, deleted) to a soft
   banner above the AppBar that lets the user keep playing cached
   content while they update.

2. 1h-throttled background check. New VersionCheckController
   (AsyncNotifier) hydrates from a secure-storage cache on boot,
   returning the cached result instantly. If the cache is missing
   or >1h old, fires a background recheck. AppLifecycleState.resumed
   triggers recheckIfStale so foregrounding after >1h re-checks
   without per-frame hammering. "Check now" button on the banner
   bypasses the staleness gate so dev iteration (push new APK, want
   to see banner clear) doesn't wait an hour.

3. Bounded health-check dio. The /healthz request uses a dedicated
   dio with connectTimeout: 3s + receiveTimeout: 2s rather than the
   default 8s / 30s. Health probes should fail fast — if the server
   can't ack in 5s, the user has bigger problems than a stale
   min_client_version and the cached value remains in effect.

Cache keys live alongside the existing tz cadence cache in
flutter_secure_storage (kResult + kAtMs). On any network error or
parse failure, _runCheck soft-fails without bumping the timestamp,
so the next staleness check will retry.

VersionTooOldBanner renders in _ShellWithPlayerBar's Column above
the existing UpdateBanner — the two coexist when both apply
(server rejects you AND an APK is queued).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:14:42 -04:00
bvandeusen 7cfaafd360 feat(#357): library_changes retention compactor
Closes the last deferred follow-up from #357. The library_changes
table is the append-only change log that drives /api/library/sync's
delta semantics — every mutation (scanner upsert, like, playlist
edit, track delete) writes one row. Without a retention policy the
table grows unbounded; the original migration (0025) called out the
follow-up explicitly.

New goroutine: sync.Compactor runs daily, deletes rows where
changed_at < now - 30 days. Logs a row count when non-zero so
operators can see compaction activity in the journal. First tick
fires on startup so a process that hasn't been compacted in a
while catches up immediately.

30-day retention matches the offline-mode spec
(docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md).
Clients with a cursor older than that hit the existing 410 fallback
path and resync from scratch.

Imported as syncpkg in main.go to follow the existing convention
(see internal/library/scanner.go).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:49:07 -04:00
bvandeusen 22bd06a578 feat(flutter): drift-first homeProvider (#357 deferred follow-up)
Home screen is the first surface on app open; without a local cache
the cold-start blocks on /api/home, which dominates felt latency on
slow or remote connections. This commit caches the last successful
HomeData as a single-row JSON blob in drift, so subsequent app opens
yield content immediately and revalidate in the background.

Schema:
  - New CachedHomeSnapshot table (single row: id=1, json TEXT,
    updated_at). schemaVersion bumped 2 → 3 with a forward migration
    that calls m.createTable(cachedHomeSnapshot). Codegen regenerated
    via build_runner; the *.g.dart files are gitignored and rebuilt
    by the CI Codegen step.

Provider rewrite:
  - homeProvider: FutureProvider<HomeData> → StreamProvider<HomeData>
    using the existing cacheFirst<CachedHomeSnapshotData, HomeData>
    pattern (alwaysRefresh: true for SWR). On cold cache the first
    /api/home fetch populates the row. On warm cache the cached
    HomeData is yielded immediately and a background REST fetch
    overwrites the row, which drift's watch() picks up.

  - Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so
    HomeData survives the JSON round-trip into and out of drift.
    Field names match the server's /api/home wire shape exactly so
    HomeData.fromJson handles both fresh server responses and cached
    drift rows.

Callers untouched: home_screen.dart's ref.watch + ref.refresh +
metadata_prefetcher's ref.listen all keep working with the
StreamProvider shape (AsyncValue<HomeData> stays the surface type).

Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart
switched from `(ref) async => _emptyHome` (FutureProvider form) to
`(ref) => Stream.value(_emptyHome)` (StreamProvider form).

For #357. Completes the user-visible deferred follow-up. Remaining
deferred items: library_changes server-side retention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:40:43 -04:00
bvandeusen efa52484d4 fix(web): add player stub to player-store mocks for TrackRow tests
#401 introduced player.current?.id reads into TrackRow.svelte and
PlaylistTrackRow.svelte, breaking 26 test cases across 6 files whose
existing vi.mock('$lib/player/store.svelte', ...) blocks only stubbed
the functions used by the original components.

Added player: { current: undefined } to each affected mock — keeps
the existing function spies and lets the new isCurrent derivation
read a defined (false-y) player.current without blowing up.

Only updated the 6 files that failed; 16 other player-store mocks
exist across the suite but their tests don't render Track/Playlist
rows so the derivation never fires. Future tests that render those
rows will need the same stub (visible at the failure point with a
clear error message).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:05:59 -04:00
bvandeusen 3e52ff7fa3 feat(#402): wire screen-scoped providers to liveEventsProvider
#392's dispatcher only invalidates publicly-importable providers
(myQuarantine + home). Screen-scoped providers (file-private in their
feature folders) get their own ref.listen(liveEventsProvider, ...) so
they go live without needing back-edge dependencies from /shared.

Five screens wired:

- library_screen.dart _LikedTab — invalidates _likedTracksProvider /
  _likedAlbumsProvider / _likedArtistsProvider on any of the six
  track/album/artist like/unlike kinds.

- playlist_detail_screen.dart — invalidates playlistDetailProvider(id)
  on playlist.updated / playlist.tracks_changed matching the visible
  playlist_id. On playlist.deleted matching the visible id, pops back
  so the user isn't left staring at a gone playlist.

- admin_requests_screen.dart — invalidates adminRequestsProvider on
  request.status_changed (covers user create/cancel + admin
  approve/reject + reconciler complete).

- admin_quarantine_screen.dart — invalidates adminQuarantineProvider
  on any quarantine.* event (flag from a user / admin resolve / file
  delete / lidarr delete).

- requests_screen.dart (own requests) — invalidates myRequestsProvider
  on request.status_changed. Server-side events are user-scoped via
  publishRequestStatusChanged's row.UserID, so admin actions on
  someone else's request route to the right stream.

History tab is NOT wired (no server-side play.scrobbled event yet —
documented in #402 body as deferred until that event ships).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:29:57 -04:00
bvandeusen 89ded7b46c feat(#402): publish album/artist like events on the SSE bus
#392 shipped track.liked / track.unliked but skipped the album +
artist symmetric pairs. Closes that gap so the Flutter Liked tab's
albums and artists sub-lists can listen for changes the same way
the tracks sub-list will (#402 wire-up lands next commit).

publishLikeEvent already handles the entity_type dispatch; the four
handler call sites just need the new lines.

For #402 follow-up to #392.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:27:23 -04:00
bvandeusen bb9e979876 feat(web,flutter): #401 — now-playing highlight on TrackRow + PlaylistTrackRow
Cross-platform consistency: when a track is currently playing, its row
on the album / liked / history / search / playlist detail surfaces
gets the same accent-border + bg-lift treatment that QueueTrackRow
applies on the queue panel. Closes the inconsistency caught during
the #375 DRY audit (the queue row had a "you are here" indicator;
other track-row surfaces did not).

Web — TrackRow.svelte + PlaylistTrackRow.svelte:
  isCurrent = player.current?.id === track.id
  → border-l-2 border-l-accent bg-surface-hover when true.
  PlaylistTrackRow additionally gates on !isUnavailable so deleted
  rows never match a phantom playing id.

Flutter — TrackRow + _PlaylistTrackRow:
  TrackRow becomes a ConsumerWidget, watches mediaItemProvider, and
  wraps its InkWell in a Container whose BoxDecoration carries the
  fs.iron background + 2px fs.accent left border when current. Title
  text also shifts to fs.accent and FontWeight.w500. Same pattern for
  _PlaylistTrackRow.

No "Now playing" pill on these surfaces — the player bar already
names the track, so the accent band alone reads as enough cue.

For #401 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:05:33 -04:00
bvandeusen e282766268 fix(flutter): analyzer issues from #396 — unused import + AsyncValue API
Two issues caught by flutter analyze --fatal-infos:

- dart:ui import in album_color_extractor.dart was redundant because
  flutter/painting re-exports the Color it provides. Dropped.
- valueOrNull isn't on AsyncValue in this Riverpod version (the
  AsyncValue<Color?> nesting may also have confused the resolver).
  Switched to asData?.value which always returns the wrapped value
  on AsyncData and null on Loading/Error.

(palette_generator's "discontinued" warning is non-fatal informational
in pub; CI didn't fail on it. The package still works; alternative
swaps deferred until it actually breaks.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:55:56 -04:00
bvandeusen 046ee8d576 feat(flutter): #396 — full-screen now-playing polish
Three of the four locked items (1, 2, 4); item 6 (swipe-tabs) stays
deferred until server-side lyrics ingestion exists.

1. Dominant-color gradient backdrop. New album_color_extractor.dart
   wraps the existing AlbumCoverCache: extracts the dominant color
   via PaletteGenerator over the local file, caches in-memory keyed
   by album_id. Top 55% of the screen carries the color (0.55 alpha
   → fs.obsidian) so controls below stay legible. AnimatedContainer
   tweens the gradient across track changes.

2. Hero transition for cover art (mini bar → full screen). Stable
   kPlayerCoverHeroTag (not media.id keyed) so the transition works
   regardless of what's playing and isn't racy if media swaps mid-tap.
   flightShuttleBuilder renders the destination's Hero widget for the
   whole flight, which reads as a clean grow rather than a swap.

4. Crossfade on track change. AnimatedSwitcher around the album art,
   title, and artist+album text block, all keyed by media.id so the
   switcher fades between old and new on each track-change rebuild.
   Pairs with the AnimatedContainer gradient so the whole "what's
   playing" zone changes in lockstep.

palette_generator: ^0.3.3 added.

For #396 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:40:51 -04:00
bvandeusen b5c5dbbe76 fix(flutter): null-coalesce PlaylistTrack.streamUrl to TrackRef.streamUrl
PlaylistTrack.streamUrl is String? (nullable when track is unavailable
post-delete); TrackRef.streamUrl is required String. flutter analyze
caught the mismatch. Coalesce to empty string for unavailable rows —
they're already filtered out by the trackId != null check earlier in
the loop, so this branch is effectively unreachable but keeps the
type-checker happy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:42:59 -04:00
bvandeusen 872b0de304 feat(flutter): #393 — always-visible play button on home cards
AlbumCard / ArtistCard / PlaylistCard gain a 44dp circular play
button overlaid bottom-right of the cover art. Mirrors the
hover-revealed .play-overlay on the web cards; always visible
because hover is not a real interaction on touch.

Per-card semantics match the web:

- AlbumCard: fetches /api/albums/{id}, starts playback from track 0.
- ArtistCard: fetches /api/artists/{id}/tracks, Fisher-Yates shuffles,
  plays from index 0 (matches web's playQueue(shuffle(tracks), 0)).
- PlaylistCard: fetches /api/playlists/{id}, materializes available
  rows into TrackRef, plays from index 0. Disabled state when
  trackCount == 0 — semi-transparent button, taps ignored.

Shared PlayCircleButton widget manages loading state (spinner during
fetch) so each card's onPressed can stay async without re-entrancy
guards. Cards become ConsumerWidget so they can reach the
playerActionsProvider + relevant API providers; constructor surface
unchanged so existing call sites (artist_detail, library_screen,
home_screen) keep working.

CompactTrackCard unchanged — its tap already plays-from-here.

For #393 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:35:57 -04:00
bvandeusen 89d8b4b5a0 feat(flutter): send timezone on login + app-start + weekly
Flutter client posts FlutterTimezone.getLocalTimezone() to
PUT /api/me/timezone on every setSession (login / register success)
and on every AuthController.build (app cold-start with valid
session), when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in flutter_secure_storage so it survives app
restarts.

Failures swallowed: the server's UTC default + last-known value
keep the scheduler functioning until the next attempt.

Completes the client side of #392 Half B (per-user timezone
scheduling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:16:52 -04:00
bvandeusen 6b7e4f1dee feat(web): send timezone on login + bootstrap + weekly
Web client posts Intl.DateTimeFormat().resolvedOptions().timeZone to
PUT /api/me/timezone after every successful login, register, and
bootstrap when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in localStorage keeps the server stateless on the
"is this stale?" check.

Failures swallowed: the server's UTC default + last-known value keep
the scheduler functioning until the next successful attempt. SSR-
safe via the typeof window guard.

For #392 Half B. Companion Flutter change in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:14:21 -04:00
bvandeusen c2168afbf0 fix(playlists): use CRON_TZ-prefixed cron expressions for per-job timezone
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:

  CRON_TZ=America/New_York 0 3 * * *

Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.

Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.

Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:09:02 -04:00
bvandeusen 7fac264c73 feat(playlists): wire Refresh into PUT /api/me/timezone + registration
Completes the server side of #392 Half B.

PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.

POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.

system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:58:24 -04:00
bvandeusen 46c8edfa82 feat(playlists): gocron-based per-user scheduler
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.

Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.

Stop drains and shuts down the gocron loop.

Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.

Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:57:13 -04:00
bvandeusen 230da7bdcb feat(users): timezone column + PUT /api/me/timezone
Schema + endpoint scaffolding for #392 Half B (per-user timezone
scheduling). Adds two columns to the users table:

  - timezone text NOT NULL DEFAULT 'UTC' (IANA name)
  - timezone_updated_at timestamptz (nullable; populated on each PUT)

PUT /api/me/timezone validates the IANA value via time.LoadLocation
and writes the row. No scheduler integration yet — the scheduler
struct lands in the next commit and the handler-side Refresh call
in the commit after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:54:45 -04:00
bvandeusen 5cd342d521 feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists
Five diversity mechanics — applied to both For-You and Songs-Like-X.

1. For-You seed rotates daily across the user's top-5 most-played
   tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
   len(seeds) so today's mix uses an entirely different similarity
   pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
   by userIDHash(user, day) rather than the no-op, so near-tied
   candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
   playlist comes from the tail now (daily-deterministic via
   tieBreakHash), giving the user substantially different content
   while a 12-track anchor of strong similarity matches keeps the
   mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
   played artists. pickSeedArtistsForDay applies a userIDHash-seeded
   Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
   parameter so the RNG can be seeded per-user; existing call sites
   updated; noopRNG removed.

Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.

PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.

For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:19:49 -04:00
bvandeusen b4801c2dd3 refactor(playlists): SQL queries return top-5 candidate seeds
For-You + Songs-Like-X seed selection moves to Go-side daily rotation
(next commit). The SQL change just widens the candidate pool: top-5
played tracks instead of single top played track; top-5 artists
instead of top-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:04:39 -04:00
bvandeusen 90d8aae51a feat(#392): Flutter SSE consumer + dispatcher
Slice 4 — completes the #392 hybrid live-refresh loop.

live_events_provider.dart subscribes to /api/events/stream via dio's
streaming response mode, parses SSE frames (kind + JSON data + UserID
scope), and exposes them as a Riverpod StreamProvider. Heartbeat
comments are silently dropped; malformed JSON frames are skipped. The
provider auto-rebuilds when auth state changes (token rotation,
sign-out → sign-in), so reconnect is implicit.

live_events_dispatcher.dart listens to the stream and invalidates the
small set of publicly-importable providers we know about:

  - myQuarantineProvider + homeProvider on any quarantine.* event
  - homeProvider on any playlist.* event (Home renders the Playlists row)

Screen-private providers (library_screen.dart's _liked* /
_libraryAlbums / _history, admin screens, etc.) opt in to live-refresh
by themselves listening to liveEventsProvider in follow-up commits;
the dispatcher stays small and avoids back-edge dependencies on every
feature folder.

The dispatcher also installs an AppLifecycleState observer for
resume-time defensive invalidation. SSE will catch up on its own when
the app returns from background, but the invalidate flushes any stale
data immediately so the first frame back is fresh.

app.dart wires the dispatcher into the post-first-frame callback
alongside the other startup activations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:25:15 -04:00
bvandeusen 15063ca0b4 fix(#392): simplify uuid formatter — gofmt-clean loop
The hand-unrolled byte-pair version in reconciler.go tripped gofmt -s
and goimports. Replaced with the same loop-based formatter that
internal/library/eventbus.go uses — same behaviour, fewer lines, lint
clean. Two copies of the helper still exist (one per package) to keep
the no-back-edge property for both internal/library and
internal/lidarrrequests, but they're now identical and the duplication
is tiny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:21:38 -04:00
bvandeusen f7dfeff256 feat(#392): scan run lifecycle events on the SSE bus
Slice 3c — completes the server-side producer set.

Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.

Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).

This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:52:06 -04:00
bvandeusen 84fc6b8d1b feat(#392): lidarr reconciler publishes request.status_changed on complete
Slice 3b — extends event publishing to background workers.

When the reconciler matches an approved Lidarr request against the
library and flips it to 'completed', it now publishes a
request.status_changed event scoped to the original requester so their
/requests page invalidates without polling. Three call sites — one per
kind (artist / album / track) — capture the completed row instead of
discarding it so the publish has the user_id.

Bus plumbing: the reconciler runs in cmd/minstrel/main.go which
constructs services before server.New is called. Moved bus
construction into main; the Server struct gained a Bus field that
Router() reads, falling back to a fresh local instance when nil
(test contexts). Result: one bus per process shared by every
publisher and the SSE subscriber endpoint.

reconciler.go inlines a uuid->string helper rather than reaching into
internal/api for one — avoids a back-edge dependency.

Test compat: 9 NewReconciler call sites in reconciler_integration_test.go
get `nil` for the new bus param. The reconciler's publishCompleted helper
is a no-op when the bus is nil so tests that don't care about events
keep passing.

Scanner producer (scan.progress) deferred to slice 3c — needs more
thought about which lifecycle transitions warrant events vs. flood the
stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:38:49 -04:00
bvandeusen ee7f0cdb42 feat(#392): publish quarantine + playlist mutation events
Slice 3a — extends the producer set onto the SSE bus.

quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
  clients invalidate their Hidden tab and admins' clients invalidate
  their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
  actions): broadcast because a single admin action can affect every
  user who'd flagged that track.

playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
  Reorder. Owner-scoped. Single kind for all three mutations because
  the client invalidation logic is identical (refetch the detail).

Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:05 -04:00
bvandeusen 3ffa5608d8 feat(#392): publish track-like + request-status events to SSE bus
Slice 2 of #392 — wires the first producers onto the bus that slice 1
built. After this commit, an SSE subscriber sees real events fire:

- track.liked / track.unliked when the user toggles the heart on a track
  (handleLikeTrack / handleUnlikeTrack). Album + artist like events
  intentionally deferred — they're symmetric trivial follow-ups but the
  operator's primary like surface is tracks.
- request.status_changed when a Lidarr request is created, cancelled,
  approved, or rejected. Auto-approve will fire twice (pending then
  approved) in rapid succession, which is semantically correct; client
  invalidation handles that fine.

Events are user-scoped via row.UserID so admin approve/reject route to
the requester, not the admin acting. Helpers live in events_publish.go
so the wire shape (kind names, payload keys) stays in one place — future
producers in slice 3 reuse the same pattern.

events_publish.go is no-op when h.eventbus is nil so tests that
construct handlers without a bus continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:55:33 -04:00
bvandeusen a5500aeeff fix(#392): update library_test.go for new Mount signature
go vet caught a missed test caller of api.Mount after slice 1's signature
change added the *eventbus.Bus parameter. Pass eventbus.New() in the test
— the TestRoutesRegisteredInMount test only walks the route table for
existence, never publishes or subscribes, so a throwaway bus is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:49:55 -04:00
bvandeusen 170614baf1 feat(#392): SSE event stream foundation — eventbus + /api/events/stream
Slice 1 of the #392 hybrid live-refresh work. Ships the in-process pub/sub
bus and the SSE subscriber endpoint; no producers wired yet, so the stream
emits only heartbeats today. Verifiable in isolation by curl-ing the
endpoint with a valid Bearer token — the connection opens, ": heartbeat"
lines arrive every 15s, the connection closes cleanly on client disconnect.

eventbus.Bus is a small fan-out broadcaster: subscribers register through
Subscribe (returns a receive channel + an unsubscribe closure), writers
call Publish, and the bus drops events for any subscriber whose buffer is
full rather than blocking the writer. No persistence — clients are
expected to resync via normal /api/* fetches on (re)connect.

The SSE handler emits an initial ": connected" comment so the client sees
the connection open immediately, then forwards events whose UserID matches
the authenticated user (or is empty for broadcast). Heartbeat comments
keep proxy connections alive. Context cancellation cleanly tears down the
subscription on client disconnect.

Producers (likes, request status, quarantine, scanner, playlist mutations)
land in subsequent slices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:44:37 -04:00
bvandeusen b466b6494a fix(flutter): unhide button + cover + timestamp on Library Hidden tab
Three small parity gaps in _HiddenTab vs the web /library/hidden page:

- No unhide affordance — once a track was flagged via the kebab, the only
  way to reverse it was to find the track in another surface and toggle
  via the kebab again. Added an Icons.restore IconButton on each tile that
  calls myQuarantineProvider.notifier.unflag(trackId) (the optimistic
  remove-with-rollback already lived on the notifier).
- No album cover art — added a 56px ServerImage thumb matching the web
  page's 14×14 thumb, with the same fs.slate fallback compact_track_card
  uses for missing covers.
- No relative timestamp — appended _relativeTime(row.createdAt) next to
  the reason pill so the user can tell "I hid this 3d ago" at a glance.

Also collapsed the duplicate provider: _HiddenTab was watching a local
FutureProvider that didn't see flag/unflag mutations, while the kebab's
HideTrackSheet flow goes through the canonical myQuarantineProvider
(AsyncNotifier). Switched _HiddenTab to watch myQuarantineProvider so
flag-from-anywhere and unhide-from-the-tab stay in sync. The local
_quarantineProvider was deleted; one source of truth now.

Caught during the #375 DRY audit cross-check against the #356 inventory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:04 -04:00
bvandeusen 9acad5461e refactor(web): extract emptyPlaylistsMock + apiClientMock test helpers
The DRY pass audit (#375) found two inline mock patterns repeated across the
vitest suite: a default empty-playlists mock for $lib/api/playlists (4 exact
copies in menu/card tests) and an api-client spy mock for $lib/api/client
(9 callers split between get-only and full-RESTy shapes — unified into one
helper that always returns all four verb spies).

Mirrors the existing test-utils/mocks/likes.ts and test-utils/mocks/quarantine.ts
convention. Tests with intentionally divergent shapes (AddToPlaylistMenu's
richer createPlaylist payload, PlaylistCard's getPlaylist, route-specific
mocks, events.svelte's specific resolved value) stay inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:45:15 -04:00
bvandeusen 1f0f7eee1a fix(playlists): unblock Discover by removing SELECT DISTINCT + ORDER BY plan error
The cross-user bucket query combined SELECT DISTINCT with ORDER BY md5(...),
which Postgres rejects at plan time (SQLSTATE 42P10). buildDiscoverCandidates
returned that error on first bucket failure, so the whole Discover playlist
was skipped every nightly run — even though the random bucket pool was
healthy. Switched to GROUP BY so the md5 ordering expression no longer needs
to appear in the select list, and hardened the function so future single-
bucket failures degrade gracefully via slot redistribution instead of taking
out the whole playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:01 -04:00
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
bvandeusen 37134950a5 Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main 2026-05-11 15:21:58 +00:00
bvandeusen f4d07ef9a1 diag(flutter): trace playlist provider — wire vs drift vs filter
Web UI shows system playlists; Flutter shows placeholders. Wire shape,
adapters, drift schema, and filter constants all line up on inspection,
so adding instrumentation to pinpoint where the system rows fall out:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
2026-05-11 08:15:58 -04:00
bvandeusen 2a5b6970e9 feat(flutter): show album name beneath artist in full player
Title (22pt parchment) → artist (14pt ash) → album (12pt ash). Album
is conditional — drops out if MediaItem.album is empty rather than
leaving a blank line. Tightened the gap to the seek bar (24→20) to
absorb the new line on small screens.
2026-05-11 08:12:29 -04:00
bvandeusen 59427bcf2f fix(flutter): full player unmounts the shell — no doubled player UI
You correctly diagnosed: the mini bar was still visible underneath
the full player, and that's also why the bottom controls overflowed
by 45px (the shell's mini bar was eating the bottom 88dp).

Move /now-playing OUT of the ShellRoute and up to a top-level GoRoute.
Now pushing /now-playing unmounts the shell entirely — no mini bar
underneath, no fight for the bottom strip. The slide-up transition
still feels right because the shell stays painted for the duration of
the animation (so during the transition both are briefly visible, as
you noted is fine).

Drop the hideMini conditional in _ShellWithPlayerBar — no longer
needed since the shell isn't even built when the full player is on
top. Recovering the 88dp also resolves the bottom-controls overflow
without needing to shrink the layout.
2026-05-11 08:09:53 -04:00
bvandeusen 163c1174db feat(flutter): mini ↔ full player transition + flesh out NowPlayingScreen
Mini player simplifies to track-info + prev/play/next. The
shuffle/repeat/queue cluster moves to the full player, where it has
the room to breathe.

Full player (NowPlayingScreen) now has:
- Real album art (FileImage when artUri is file://, ServerImage from
  /api/albums/<id>/cover otherwise — same auth-aware loader as the
  rest of the app, with errorBuilder so a failed fetch falls back to
  a slate placeholder instead of a broken-image glyph).
- Title row with inline like + kebab.
- Full-width seek with start/end timestamps below.
- 36/72/36 prev/play/next.
- Secondary row: shuffle, repeat, queue.

Mini ↔ Full transition mechanics:
- Tap mini → push /now-playing with custom slide-up transition
  (CustomTransitionPage, 280ms easeOutCubic).
- Vertical drag-up flick on mini (>200 px/s) → same push.
- System back / leading expand-more icon → Navigator.pop, which the
  CustomTransitionPage replays as a slide-down (240ms easeInCubic).
- Drag-down on full player past 80px OR a >500 px/s downward flick
  → Navigator.pop. Buttons and the seek slider stay tappable since
  Flutter's gesture arena gives priority to the more-specific child
  recognizers.
- Mini player hides while /now-playing is the active route — the
  full player IS the player UI in that state, no need to fight over
  visual space.
2026-05-11 08:06:38 -04:00
bvandeusen 3162fedd3b fix(flutter): card taps via Material+InkWell, not GestureDetector
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.

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

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

Material color is transparent so the existing dark page background
shows through unchanged.
2026-05-11 07:57:10 -04:00
bvandeusen a05a279508 fix(flutter): strip duplicate status-bar inset when update banner shows
You correctly diagnosed the symptom: the AppBar gets taller when the
banner is present. Why: the shell is [Banner, Expanded(child),
PlayerBar] and the banner uses SafeArea(bottom:false) to clear the
status bar. But MediaQuery.padding is screen-relative — the child's
Scaffold/AppBar reads MediaQuery.padding.top and applies its own
status-bar inset on top of the banner, doubling the gap.

Watch shouldShowUpdateBannerProvider in the shell. When it returns
non-null (banner visible), wrap the child in MediaQuery.removePadding
removeTop: true so the AppBar mounts directly under the banner. When
the banner is hidden, child gets its normal top inset.
2026-05-11 07:52:45 -04:00
bvandeusen a3985f1138 fix(flutter): cover images — wait for token, file:// loader, graceful errors
The 401s on /api/albums/<id>/cover were the root cause of "only the
first tile is navigable" — failed images leaving slate placeholders
that combined with the deferToChild hit-test (fixed in d703fc2) to
silently swallow taps.

ServerImage:
- sessionTokenProvider is a FutureProvider; .value is null after a
  hot restart until secure-storage resolves. Old code fired
  Image.network with headers:null at that moment, the network image
  cache locked in the 401 response, and never retried. Switch to
  AsyncValue.when so the widget waits for the token before mounting
  Image.network with the auth header attached.
- Add errorBuilder so a single failed image renders the parent
  fallback instead of leaking a stack trace + broken-icon glyph.

Player bar:
- media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping
  it in NetworkImage attempts HTTP on a file:// URI and fails. Use
  FileImage when the scheme is file, else NetworkImage.

Net effect: failed image loads no longer leak errors or block other
widgets from rendering / receiving taps.
2026-05-11 07:44:28 -04:00
bvandeusen 7cabe4efef fix(flutter): player bar — title visible, controls stacked, no overflow
Three issues from the screenshot:

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

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

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

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

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

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

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

This adds the actual attributes.

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

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

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

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

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

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

### audio_handler.dart

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

### player_provider.dart

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

### player_bar.dart

Complete rebuild matching the operator-designed layout:

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

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

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

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

### Compact view (below md)

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

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

### Desktop view (md+)

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

### Cleanup

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:33:10 -04:00
143 changed files with 8357 additions and 1337 deletions
+42 -6
View File
@@ -72,9 +72,44 @@ 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')
run: flutter build apk --release
# Inject the tag (sans leading 'v') as the build's --build-name
# so PackageInfo.version matches what /api/client/version
# 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}"
- name: Upload debug APK artifact
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
@@ -87,14 +122,15 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
# Look up the release ID for this tag (release must exist already;
# release.yml creates it as part of the server-side release flow).
# Look up the release ID for this tag. The release object must
# exist already — operator creates it (or release.yml will, in
# a future enhancement) before pushing the tag.
RELEASE_ID=$(curl -fsSL \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "${RELEASE_ID}" ]; then
@@ -102,6 +138,6 @@ jobs:
exit 1
fi
curl -fsSL \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
+9 -4
View File
@@ -50,9 +50,11 @@ jobs:
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest"
else
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main"
fi
@@ -60,7 +62,7 @@ jobs:
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
# In-app update flow (#397): on tag pushes only, fetch the APK
@@ -72,14 +74,14 @@ jobs:
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
for i in $(seq 1 30); do
if curl -fsSL -H "Authorization: token ${RELEASE_TOKEN}" \
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
-o client/minstrel.apk "$APK_URL"; then
echo "Got APK on attempt $i"
echo "${TAG}" > client/minstrel.apk.version
@@ -93,4 +95,7 @@ jobs:
- name: Build and push
if: steps.guard.outputs.ready == 'true'
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
run: |
docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--push ${{ steps.tags.outputs.args }} .
+7 -1
View File
@@ -15,7 +15,13 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# build-arg; local `docker build` falls back to "dev". Surfaced at
# /healthz for operator-side image-version verification.
ARG MINSTREL_VERSION=dev
RUN go build -trimpath \
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
RUN apt-get update \
+34 -2
View File
@@ -14,6 +14,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
@@ -24,6 +25,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
func main() {
@@ -146,9 +148,38 @@ func run() error {
// import requests and reconciles them against the library. Short-circuits
// to no-op when lidarr_config.enabled = false.
lidarrCfg := lidarrconfig.New(pool)
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"))
// Live-event bus shared between SSE subscribers (api.Mount) and
// background workers that publish (reconciler today; scanner later).
// Constructed before any service that publishes so they all share the
// same instance.
bus := eventbus.New()
// Scan-run lifecycle events use a package-level setter rather than
// threading the bus through RunScan + TryStartScan + the Scheduler
// + every test caller. Per-process singleton, set once at startup.
library.SetEventBus(bus)
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus)
go lidarrReconciler.Run(ctx)
// library_changes compactor (#357 follow-up). Daily tick; deletes
// rows older than the configured retention so the change-log table
// doesn't grow unbounded. Clients that drop offline longer than
// retention hit the /api/library/sync 410 fallback and resync.
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
go libraryChangesCompactor.Run(ctx)
// Per-user system-playlist scheduler (#392 Half B). Fires each
// active user's daily build at 03:00 in their stored timezone.
// Replaces the 24h-anchored cron loop (removed in the next commit
// of this arc).
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
if err != nil {
return fmt.Errorf("init playlist scheduler: %w", err)
}
if err := playlistScheduler.Start(ctx); err != nil {
return fmt.Errorf("start playlist scheduler: %w", err)
}
defer playlistScheduler.Stop()
// Ensure DataDir exists before any service tries to write into it.
// Fatal on failure: a non-writable data_dir silently breaks every
// downstream cache (playlist covers, artist art, album-cover fallback)
@@ -173,7 +204,8 @@ func run() error {
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
+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")
}
}
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
@@ -12,6 +13,15 @@ class AdminQuarantineScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// #402 wire-up: any quarantine event (flag from a user / admin
// resolve / file delete / lidarr delete) refreshes the admin queue.
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e == null) return;
if (e.kind.startsWith('quarantine.')) {
ref.invalidate(adminQuarantineProvider);
}
});
final items = ref.watch(adminQuarantineProvider);
return Scaffold(
backgroundColor: fs.obsidian,
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/admin_user.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
@@ -13,6 +14,15 @@ class AdminRequestsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// #402 wire-up: invalidate the admin requests list when a user
// creates/cancels/admin approves/rejects/reconciler completes —
// request.status_changed covers all of them.
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e?.kind == 'request.status_changed') {
ref.invalidate(adminRequestsProvider);
}
});
final requests = ref.watch(adminRequestsProvider);
// Best-effort lookup for requester usernames. If the users provider
// hasn't loaded yet, valueOrNull is null and rows fall back to the
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import '../../models/album.dart';
import '../../models/artist.dart';
import '../../models/home_data.dart';
import '../../models/home_index.dart';
import '../../models/track.dart';
/// LibraryApi wraps the server's native /api/* library surface.
@@ -27,6 +28,24 @@ class LibraryApi {
return HomeData.fromJson(r.data ?? const {});
}
/// GET /api/home/index — per-item rendering variant. Returns just IDs
/// per section; client hydrates each tile via the per-entity
/// endpoints. ~10× smaller than /api/home on populated libraries so
/// the cold-visit round-trip is correspondingly short.
Future<HomeIndex> getHomeIndex() async {
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
return HomeIndex.fromJson(r.data ?? const {});
}
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
/// the HydrationQueue to populate cached_tracks on a per-tile miss
/// — the existing endpoint already joins album + artist so the
/// response carries everything TrackRef needs.
Future<TrackRef> getTrack(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
return TrackRef.fromJson(r.data ?? const {});
}
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
/// it cares about, so passing the whole body is correct.
@@ -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);
}
+8
View File
@@ -38,4 +38,12 @@ class MeApi {
);
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
}
/// PUT /api/me/timezone — submit the device's IANA timezone. The
/// scheduler uses this to fire the user's daily playlist build at
/// 03:00 in their local time. See AuthController._sendTimezoneIfStale
/// for the weekly cadence trigger.
Future<void> putTimezone(String timezone) async {
await _dio.put<void>('/api/me/timezone', data: {'timezone': timezone});
}
}
@@ -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);
}
}
+12
View File
@@ -1,8 +1,10 @@
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/live_events_dispatcher.dart';
import 'shared/routing.dart';
import 'theme/theme_data.dart';
import 'theme/theme_mode_provider.dart';
@@ -27,6 +29,16 @@ 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);
// Live events (#392): subscribes to /api/events/stream and
// invalidates publicly-scoped providers when relevant events
// arrive. Also installs an AppLifecycleState observer for
// resume-time defensive invalidation.
ref.read(liveEventsDispatcherProvider);
});
}
+37 -4
View File
@@ -2,12 +2,17 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import '../api/endpoints/me.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/user.dart';
const _kServerUrl = 'server_url';
const _kSessionToken = 'session_token';
const _kCurrentUser = 'current_user';
const _kServerUrl = 'server_url';
const _kSessionToken = 'session_token';
const _kCurrentUser = 'current_user';
const _kTzLastSentAt = 'tz_last_sent_at';
const _weeklyMs = 7 * 24 * 60 * 60 * 1000;
final secureStorageProvider = Provider<FlutterSecureStorage>(
(ref) => const FlutterSecureStorage(),
@@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier<User?> {
_storage = ref.watch(secureStorageProvider);
final raw = await _storage.read(key: _kCurrentUser);
if (raw == null) return null;
return User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
final user = User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
// Fire-and-forget timezone send on app start with an existing
// session — no-op when last_sent_at is fresh.
// ignore: unawaited_futures
_sendTimezoneIfStale();
return user;
}
Future<void> setServerUrl(String url) async {
@@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier<User?> {
await _storage.write(key: _kCurrentUser, value: userJson);
ref.invalidate(sessionTokenProvider);
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
// ignore: unawaited_futures
_sendTimezoneIfStale();
}
Future<void> clearSession() async {
@@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier<User?> {
ref.invalidate(sessionTokenProvider);
state = const AsyncData(null);
}
/// Sends the device's current IANA timezone to PUT /api/me/timezone
/// when the last successful send was >7 days ago (or never).
/// Cadence persists in flutter_secure_storage so it survives app
/// restarts. Failures are swallowed: the server keeps its previous
/// value (or 'UTC' default) until the next attempt.
Future<void> _sendTimezoneIfStale() async {
try {
final lastStr = await _storage.read(key: _kTzLastSentAt);
final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0;
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (nowMs - lastMs < _weeklyMs) return;
final tz = await FlutterTimezone.getLocalTimezone();
if (tz.isEmpty) return;
final dio = await ref.read(dioProvider.future);
await MeApi(dio).putTimezone(tz);
await _storage.write(key: _kTzLastSentAt, value: nowMs.toString());
} catch (_) {
// Non-fatal — server falls back to UTC or last-known value.
}
}
}
final authControllerProvider =
+13 -1
View File
@@ -34,12 +34,18 @@ extension ArtistRefDriftWrite on ArtistRef {
extension CachedAlbumAdapter on CachedAlbum {
/// `artistName` is supplied by the joined CachedArtists row at query time.
/// `coverUrl` is reconstructed deterministically from the album id —
/// the server emits the same shape (see internal/api/convert.go:69).
/// We don't need to persist it, so AlbumRef.coverUrl is non-empty
/// even when the row was populated from a sync that didn't carry the
/// derived URL.
AlbumRef toRef({String artistName = ''}) => AlbumRef(
id: id,
title: title,
sortTitle: sortTitle,
artistId: artistId,
artistName: artistName,
coverUrl: '/api/albums/$id/cover',
);
}
@@ -82,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,
@@ -90,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:
+40 -1
View File
@@ -5,12 +5,24 @@
// - empty + online → fetch via REST, populate drift, await re-emission
// - empty + offline → yield mapped empty result (UI shows empty state)
//
// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh
// in the background after the first non-empty emission. Use for
// aggregate lists (playlists, etc.) where the server may have rows
// the local sync didn't pick up — yields cache immediately, refreshes
// silently, and drift watch() re-emits with whatever new rows landed.
//
// The pattern lets every read provider trust drift as the source of
// truth. SyncController keeps drift fresh in the background; widget
// rebuilds happen automatically as drift writes propagate via watch().
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>`)
@@ -28,10 +40,26 @@ Stream<T> cacheFirst<D, T>({
required Future<void> Function() fetchAndPopulate,
required T Function(List<D>) toResult,
required Future<bool> Function() isOnline,
bool alwaysRefresh = false,
String? tag,
}) async* {
// Tracks whether we've already kicked off a stale-while-revalidate
// refresh for this stream subscription, so we don't fire one on every
// drift re-emission (otherwise the populate cycles forever).
var revalidated = false;
await for (final rows in driftStream) {
if (rows.isNotEmpty) {
yield toResult(rows);
// Stale-while-revalidate: yield cache immediately, then kick off
// a REST refresh in the background. Drift watch() picks up the
// resulting writes and re-emits via this same stream loop.
// Useful for aggregate lists (e.g. playlists) where the server
// may have rows the local sync didn't pick up.
if (alwaysRefresh && !revalidated && await isOnline()) {
revalidated = true;
unawaited(_safeFetch(fetchAndPopulate));
}
continue;
}
if (await isOnline()) {
@@ -39,7 +67,10 @@ Stream<T> cacheFirst<D, T>({
await fetchAndPopulate();
// The drift watch() stream re-emits with the populated rows on
// the next loop iteration. Don't yield here.
} catch (_) {
} catch (e, st) {
if (tag != null) {
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
}
yield toResult(rows); // empty result; caller surfaces error
}
} else {
@@ -47,3 +78,11 @@ Stream<T> cacheFirst<D, T>({
}
}
}
Future<void> _safeFetch(Future<void> Function() fn) async {
try {
await fn();
} catch (_) {
// Background revalidate — swallow; UI already showed cached state.
}
}
+26 -4
View File
@@ -5,9 +5,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator
/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and
/// pull/cache".
final connectivityProvider = StreamProvider<bool>((ref) {
///
/// IMPORTANT: onConnectivityChanged only emits on *changes*, not on
/// subscription. Without seeding an initial value via checkConnectivity(),
/// any consumer using `ref.read(connectivityProvider.future)` would
/// block until the OS happened to report a connectivity flip — which
/// is exactly what made album/artist/playlist detail screens spin
/// forever for tiles tapped before the first event landed.
final connectivityProvider = StreamProvider<bool>((ref) async* {
final c = Connectivity();
return c.onConnectivityChanged.map(
(results) => results.any((r) => r != ConnectivityResult.none),
);
bool isOnline(List<ConnectivityResult> rs) =>
rs.any((r) => r != ConnectivityResult.none);
// checkConnectivity() goes over a platform channel and on some
// Android builds it can stall. Fall back to "assume online" after
// 2s so consumers waiting on .future never block forever — being
// wrong about connectivity costs at most one failed request, but
// being stuck spins the UI indefinitely.
try {
final initial = await c.checkConnectivity().timeout(
const Duration(seconds: 2),
onTimeout: () => const [ConnectivityResult.wifi],
);
yield isOnline(initial);
} catch (_) {
yield true;
}
yield* c.onConnectivityChanged.map(isOnline);
});
+101 -1
View File
@@ -115,6 +115,77 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {id};
}
/// Single-row cache of the /api/home response (#357 follow-up). The
/// home screen is the first thing the user sees on app open; storing
/// the last successful HomeData as JSON lets us yield it immediately
/// before the REST round-trip resolves, eliminating the cold-start
/// blank on slow / remote connections. Schema 3+.
class CachedHomeSnapshot extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
TextColumn get json => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
/// Single-row cache of the /api/me/history response. Mirrors the
/// CachedHomeSnapshot pattern: opening the History tab in the Library
/// screen yields the last-known page immediately while a fresh REST
/// pull lands underneath via SWR. Also makes basic offline scrollback
/// possible — the last fetched page survives both app restart and
/// loss of connectivity. Schema 4+.
class CachedHistorySnapshot extends Table {
IntColumn get id => integer().withDefault(const Constant(1))();
TextColumn get json => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
/// Section→position→entity-id index for the home screen, populated
/// from the per-item discovery endpoint `/api/home/index`. Each row
/// pins one tile slot to an entity; the actual entity data lives in
/// cached_albums / cached_artists / cached_tracks. The home screen
/// reads this table to know the layout, then hydrates each tile
/// against the entity tables (per-item rendering). Schema 6+.
class CachedHomeIndex extends Table {
/// One of: 'recently_added_albums', 'rediscover_albums',
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
/// Mirrors the keys /api/home and /api/home/index emit.
TextColumn get section => text()();
IntColumn get position => integer()();
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
/// to the right per-entity endpoint.
TextColumn get entityType => text()();
TextColumn get entityId => text()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {section, position};
}
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
/// /api/quarantine/mine — fully denormalized (track + album + artist
/// fields inline) because the Hidden tab renders straight from this row
/// without joining other tables. Columnar (vs JSON blob) so flag/unflag
/// can do row-level INSERT/DELETE; the drift watch() emission feeds
/// MyQuarantineController state directly. Schema 5+.
class CachedQuarantineMine extends Table {
TextColumn get trackId => text()();
TextColumn get reason => text()();
TextColumn get notes => text().nullable()();
TextColumn get createdAt => text()();
TextColumn get trackTitle => text()();
IntColumn get trackDurationMs => integer().withDefault(const Constant(0))();
TextColumn get albumId => text()();
TextColumn get albumTitle => text()();
TextColumn get albumCoverArtPath => text().nullable()();
TextColumn get artistId => text()();
TextColumn get artistName => text()();
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {trackId};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
@@ -126,12 +197,16 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
CachedHomeSnapshot,
CachedHistorySnapshot,
CachedQuarantineMine,
CachedHomeIndex,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 2;
int get schemaVersion => 6;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -147,6 +222,31 @@ class AppDb extends _$AppDb {
// pre-existing rows until they happen to change server-side.
await customStatement('UPDATE sync_metadata SET cursor = 0');
}
if (from < 3) {
// Schema 3: cached_home_snapshot for #357 drift-first
// home page. No existing rows to migrate — table starts
// empty; the first /api/home fetch populates it.
await m.createTable(cachedHomeSnapshot);
}
if (from < 4) {
// Schema 4: cached_history_snapshot for drift-first
// History tab. Same pattern as cached_home_snapshot —
// empty on upgrade; first /api/me/history fetch populates.
await m.createTable(cachedHistorySnapshot);
}
if (from < 5) {
// Schema 5: cached_quarantine_mine for drift-first Hidden
// tab. Empty on upgrade; first /api/quarantine/mine fetch
// populates. Optimistic flag/unflag also writes here.
await m.createTable(cachedQuarantineMine);
}
if (from < 6) {
// Schema 6: cached_home_index for per-item rendering.
// Empty on upgrade; first /api/home/index fetch populates.
// cached_home_snapshot stays in place for now — older
// builds still read from it as a fallback path.
await m.createTable(cachedHomeIndex);
}
},
);
}
+139
View File
@@ -0,0 +1,139 @@
// Per-item hydration coordinator for the home/playlist/liked surfaces.
//
// Tile widgets are reactive to their per-entity drift row. On first
// build, if drift has no row for the requested id, the tile asks this
// queue to hydrate it. The queue dispatches one /api/<type>/:id
// request, writes the result to drift, and the drift watch() on the
// tile re-emits with the populated row.
//
// Concurrency cap: HydrationQueue limits the number of in-flight
// requests so a 50-tile home screen doesn't fire 50 parallel /api/
// calls and stall the auth-token-bound dio pool. In-flight dedup
// (keyed by `<type>:<id>`) ensures a single hydration when multiple
// tiles share an entity (rare on home, common when several screens
// reference the same album).
//
// Failures are swallowed — a single failed hydration leaves the tile
// in skeleton state. A future retry-on-visit pass can layer on top
// without changing the queue's contract.
import 'dart:async';
import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../library/library_providers.dart' show libraryApiProvider;
/// Bounded-concurrency request queue for per-entity hydration. One
/// instance per Riverpod scope (provider singleton below).
class HydrationQueue {
HydrationQueue(this._ref);
final Ref _ref;
/// Concurrent slot count. 4 keeps the dio token pool from saturating
/// while still pulling tiles down faster than a serial loop. Bump
/// if profiling shows the queue idle while the network is healthy.
static const _maxConcurrent = 4;
final Queue<_HydrationRequest> _pending = Queue();
final Set<String> _inFlightKeys = {};
int _inFlight = 0;
/// Request hydration for (entityType, entityId). Idempotent — second
/// call for the same key while the first is queued or in flight is
/// dropped, so multiple tile rebuilds during a fast scroll don't
/// inflate the queue.
void enqueue({required String entityType, required String entityId}) {
if (entityId.isEmpty) return;
final key = '$entityType:$entityId';
if (_inFlightKeys.contains(key)) return;
if (_pending.any((r) => r.key == key)) return;
_pending.add(_HydrationRequest(
entityType: entityType, entityId: entityId, key: key));
_pump();
}
void _pump() {
while (_inFlight < _maxConcurrent && _pending.isNotEmpty) {
final req = _pending.removeFirst();
_inFlightKeys.add(req.key);
_inFlight++;
// Unawaited on purpose — _pump returns immediately so further
// enqueues can fill remaining slots while this one runs.
unawaited(_execute(req).whenComplete(() {
_inFlight--;
_inFlightKeys.remove(req.key);
_pump();
}));
}
}
Future<void> _execute(_HydrationRequest req) async {
try {
switch (req.entityType) {
case 'album':
await _hydrateAlbum(req.entityId);
case 'artist':
await _hydrateArtist(req.entityId);
case 'track':
await _hydrateTrack(req.entityId);
}
} catch (_) {
// Swallow — tile stays in skeleton. A retry-on-visit pass can
// layer on top later without changing this contract.
}
}
Future<void> _hydrateAlbum(String id) async {
final api = await _ref.read(libraryApiProvider.future);
final result = await api.getAlbum(id);
final db = _ref.read(appDbProvider);
// Album + tracks come bundled in the same response; persist both
// so opening the album detail later is also a drift hit.
await db.transaction(() async {
await db
.into(db.cachedAlbums)
.insertOnConflictUpdate(result.album.toDrift());
if (result.tracks.isNotEmpty) {
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList());
});
}
});
}
Future<void> _hydrateArtist(String id) async {
final api = await _ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
final db = _ref.read(appDbProvider);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
}
Future<void> _hydrateTrack(String id) async {
final api = await _ref.read(libraryApiProvider.future);
final fresh = await api.getTrack(id);
final db = _ref.read(appDbProvider);
await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift());
}
}
class _HydrationRequest {
_HydrationRequest({
required this.entityType,
required this.entityId,
required this.key,
});
final String entityType;
final String entityId;
final String key;
}
/// Singleton queue scoped to the Riverpod container. Kept as a plain
/// Provider (not StateProvider) since the queue's internal state is
/// pump-driven, not reactively observed.
final hydrationQueueProvider = Provider<HydrationQueue>((ref) {
return HydrationQueue(ref);
});
+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);
});
+72 -2
View File
@@ -1,6 +1,10 @@
import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider;
import '../library/library_providers.dart' show dioProvider;
import 'audio_cache_manager.dart' show appDbProvider;
import 'db.dart';
@@ -75,6 +79,14 @@ class SyncController extends AsyncNotifier<SyncResult?> {
var upsertCount = 0;
var deleteCount = 0;
// IDs of upserted entities whose covers we'll pre-warm after the
// transaction commits. Filled inside the loops; consumed by
// _prewarmCovers fire-and-forget below. Artist covers are
// server-derived from "most-recent album" and aren't
// reconstructible client-side — album pre-warm already covers an
// artist's primary visual via its detail-page header.
final albumCoverIds = <String>[];
final playlistCoverIds = <String>[];
await db.transaction(() async {
// ---- Upserts ----
@@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
upsertCount++;
}
for (final a in (upserts['album'] as List? ?? const [])) {
final m = a as Map<String, dynamic>;
await db.into(db.cachedAlbums).insertOnConflictUpdate(
_albumFromJson(a as Map<String, dynamic>),
_albumFromJson(m),
);
final id = m['id'];
if (id is String && id.isNotEmpty) albumCoverIds.add(id);
upsertCount++;
}
for (final t in (upserts['track'] as List? ?? const [])) {
@@ -130,9 +145,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
upsertCount++;
}
for (final p in (upserts['playlist'] as List? ?? const [])) {
final m = p as Map<String, dynamic>;
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
_playlistFromJson(p as Map<String, dynamic>),
_playlistFromJson(m),
);
final id = m['id'];
if (id is String && id.isNotEmpty) playlistCoverIds.add(id);
upsertCount++;
}
for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
@@ -224,6 +242,15 @@ class SyncController extends AsyncNotifier<SyncResult?> {
);
});
// Fire-and-forget cover pre-warm so a cold-start scroll through
// the home grid paints from disk on the very first frame instead
// of firing one HTTP request per visible tile. Unawaited because
// the sync's "done" signal should fire as soon as the metadata
// delta is durable; cover downloads can finish in the background.
if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) {
unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds));
}
final result = SyncResult(
upserts: upsertCount,
deletes: deleteCount,
@@ -237,6 +264,49 @@ class SyncController extends AsyncNotifier<SyncResult?> {
}
}
/// Downloads cover bytes for each id into the shared flutter_cache_
/// manager disk cache that cached_network_image reads from.
/// Best-effort: per-URL failures (404 collage-not-built-yet, 401
/// during a token refresh race, network blip) are swallowed so one
/// missing cover doesn't abort the rest.
///
/// Concurrency 3 balances disk-warming throughput against starving
/// foreground UI work — covers are ~30-200KB each, so three in
/// flight saturates most LAN connections without pinning the radio.
Future<void> _prewarmCovers(
List<String> albumIds,
List<String> playlistIds,
) async {
final baseUrl = await ref.read(serverUrlProvider.future);
if (baseUrl == null || baseUrl.isEmpty) return;
final token = await ref.read(sessionTokenProvider.future);
final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'}
: <String, String>{};
final base =
baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final urls = <String>[
for (final id in albumIds) '$base/api/albums/$id/cover',
for (final id in playlistIds) '$base/api/playlists/$id/cover',
];
final mgr = DefaultCacheManager();
var index = 0;
Future<void> worker() async {
while (index < urls.length) {
final i = index++;
try {
await mgr.downloadFile(urls[i], authHeaders: headers);
} catch (_) {
// Best-effort prewarm — failures are expected for system
// playlists whose collage hasn't been built yet, and for
// any transient auth race. Don't surface or abort.
}
}
}
await Future.wait(List.generate(3, (_) => worker()));
}
CachedArtistsCompanion _artistFromJson(Map<String, dynamic> j) =>
CachedArtistsCompanion.insert(
id: j['id'] as String,
+127
View File
@@ -0,0 +1,127 @@
// Per-entity tile providers for the per-item rendering architecture
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
//
// Each provider is a StreamProvider.family<EntityRef?, String> keyed
// by entity id. The stream:
// 1. Watches the entity's drift row
// 2. Yields the populated row on every emission
// 3. Yields null while the row is absent (UI shows skeleton)
// 4. On the first missing-row emission, enqueues a hydration via
// HydrationQueue so the row eventually lands
//
// The tile widget reacts to AsyncValue<EntityRef?>:
// - loading or data:null → skeleton
// - data:non-null → real card
// - error → error placeholder
//
// Subscriptions are per-tile. A 50-tile home screen creates 50 drift
// subscriptions; drift handles this fine in practice but worth
// measuring if a future surface scales to hundreds.
import 'package:drift/drift.dart' show leftOuterJoin;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../models/album.dart';
import '../models/artist.dart';
import '../models/track.dart';
import 'hydration_queue.dart';
/// Watches the cached_albums row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
final albumTileProvider =
StreamProvider.family<AlbumRef?, String>((ref, id) async* {
if (id.isEmpty) {
yield null;
return;
}
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id)))
.join([
leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
]);
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
if (!enqueued) {
enqueued = true;
ref
.read(hydrationQueueProvider)
.enqueue(entityType: 'album', entityId: id);
}
yield null;
continue;
}
final r = rows.first;
final album = r.readTable(db.cachedAlbums);
final artist = r.readTableOrNull(db.cachedArtists);
yield album.toRef(artistName: artist?.name ?? '');
}
});
/// Watches the cached_artists row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
final artistTileProvider =
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
if (id.isEmpty) {
yield null;
return;
}
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
if (!enqueued) {
enqueued = true;
ref
.read(hydrationQueueProvider)
.enqueue(entityType: 'artist', entityId: id);
}
yield null;
continue;
}
yield rows.first.toRef();
}
});
/// Watches the cached_tracks row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
final trackTileProvider =
StreamProvider.family<TrackRef?, String>((ref, id) async* {
if (id.isEmpty) {
yield null;
return;
}
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id)))
.join([
leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
]);
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
if (!enqueued) {
enqueued = true;
ref
.read(hydrationQueueProvider)
.enqueue(entityType: 'track', entityId: id);
}
yield null;
continue;
}
final r = rows.first;
final t = r.readTable(db.cachedTracks);
final a = r.readTableOrNull(db.cachedArtists);
final al = r.readTableOrNull(db.cachedAlbums);
yield t.toRef(
artistName: a?.name ?? '',
albumTitle: al?.title ?? '',
);
}
});
@@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -204,9 +205,14 @@ class _ResultTile extends StatelessWidget {
color: fs.slate,
child: row.imageUrl.isEmpty
? Icon(Icons.album, color: fs.ash)
: Image.network(row.imageUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.album, color: fs.ash)),
: CachedNetworkImage(
imageUrl: row.imageUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(Icons.album, color: fs.ash),
),
),
),
const SizedBox(width: 12),
@@ -2,32 +2,105 @@ 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';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
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),
child: Row(children: [
Container(width: 96, height: 96, color: fs.slate),
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,
@@ -5,34 +5,75 @@ 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';
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: [
Container(
width: 96, height: 96,
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
ClipOval(
child: SizedBox(
width: 96,
height: 96,
// Server derives artist cover from a representative
// album. Drift cache doesn't persist that pointer, so
// mirror the trick client-side: reuse the first album
// returned by artistAlbumsProvider. Falls back to
// slate while albums are loading or empty.
child: _ArtistAvatar(
serverCoverUrl: a.coverUrl,
albums: albums,
fs: fs,
),
),
),
const SizedBox(width: 16),
Expanded(
@@ -51,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")),
);
}
}
},
),
),
@@ -68,23 +130,79 @@ 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) => GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.8,
),
itemCount: list.length,
itemBuilder: (_, i) {
final AlbumRef album = list[i];
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
},
),
data: (list) {
return 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;
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
// ≈ 36) + slack. Artist line is suppressed in this
// grid (showArtist: false) since the page header already
// 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(),
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: list.length,
itemBuilder: (_, i) {
final AlbumRef album = list[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
showArtist: false,
onTap: () =>
context.push('/albums/${album.id}', extra: album),
);
},
);
});
},
),
]),
),
]);
}
/// Renders the artist's avatar. Server-emitted coverUrl wins when
/// non-empty; otherwise we mirror the server's "use the first album's
/// cover" rule client-side via the loaded album list.
class _ArtistAvatar extends StatelessWidget {
const _ArtistAvatar({
required this.serverCoverUrl,
required this.albums,
required this.fs,
});
final String serverCoverUrl;
final AsyncValue<List<AlbumRef>> albums;
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
if (serverCoverUrl.isNotEmpty) {
return ServerImage(
url: serverCoverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
);
}
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
if (firstId == null) {
return Container(color: fs.slate);
}
return ServerImage(
url: '/api/albums/$firstId/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
);
}
}
+259 -83
View File
@@ -4,17 +4,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/errors.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../cache/tile_providers.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
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 '../shared/widgets/skeletons.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
@@ -22,13 +21,18 @@ import 'widgets/artist_card.dart';
import 'widgets/compact_track_card.dart';
import 'widgets/horizontal_scroll_row.dart';
/// Home screen. Per-item rendering: a tiny /api/home/index discovery
/// fetch returns just section→IDs, then each tile hydrates itself
/// against the per-entity drift tables (sync-populated) with REST
/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a
/// single small round-trip; tiles materialize as their data lands.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final home = ref.watch(homeProvider);
final index = ref.watch(homeIndexProvider);
final allPlaylists = ref.watch(playlistsListProvider('all'));
final status = ref.watch(systemPlaylistsStatusProvider);
return Scaffold(
@@ -40,38 +44,36 @@ class HomeScreen extends ConsumerWidget {
actions: const [MainAppBarActions(currentRoute: '/home')],
),
body: SafeArea(
child: home.when(
child: index.when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
onRetry: () => ref.refresh(homeIndexProvider),
);
}
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(children: [
_PlaylistsSection(
playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
),
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
_RediscoverSection(
albums: h.rediscoverAlbums,
artists: h.rediscoverArtists,
),
_MostPlayedSection(tracks: h.mostPlayedTracks),
_LastPlayedSection(artists: h.lastPlayedArtists),
const SizedBox(height: 96),
]),
onRefresh: () async => ref.refresh(homeIndexProvider.future),
child: ListView(
physics: const ClampingScrollPhysics(),
children: [
_PlaylistsSection(
playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
),
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
_RediscoverSection(
albumIds: h.rediscoverAlbums,
artistIds: h.rediscoverArtists,
),
_MostPlayedSection(ids: h.mostPlayedTracks),
_LastPlayedSection(ids: h.lastPlayedArtists),
const SizedBox(height: 140),
],
),
),
),
),
@@ -79,6 +81,146 @@ class HomeScreen extends ConsumerWidget {
}
}
// ─── Per-tile widgets ────────────────────────────────────────────────
/// Duration of the skeleton→content cross-fade. 220ms reads as "tile
/// settled into place" — longer drags, shorter feels like a hard cut.
/// Each tile cross-fades independently when its data lands, so the
/// natural cascade from the hydration queue's bounded concurrency
/// produces a staged-reveal feel without any per-tile delay math.
const Duration _tileRevealDuration = Duration(milliseconds: 220);
/// Album tile: skeleton until albumTileProvider yields a populated row.
class _AlbumTile extends ConsumerWidget {
const _AlbumTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncAlbum = ref.watch(albumTileProvider(id));
final album = asyncAlbum.asData?.value;
return AnimatedSwitcher(
duration: _tileRevealDuration,
switchInCurve: Curves.easeOut,
child: album == null
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
: AlbumCard(
key: ValueKey('album-${album.id}'),
album: album,
onTap: () =>
context.push('/albums/${album.id}', extra: album),
),
);
}
}
/// Artist tile: skeleton until artistTileProvider yields a populated row.
class _ArtistTile extends ConsumerWidget {
const _ArtistTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncArtist = ref.watch(artistTileProvider(id));
final artist = asyncArtist.asData?.value;
return AnimatedSwitcher(
duration: _tileRevealDuration,
switchInCurve: Curves.easeOut,
child: artist == null
? const SkeletonArtistTile(key: ValueKey('skeleton'))
: ArtistCard(
key: ValueKey('artist-${artist.id}'),
artist: artist,
onTap: () =>
context.push('/artists/${artist.id}', extra: artist),
),
);
}
}
/// Track tile (used by Most-Played). On tap, gathers the currently-
/// hydrated TrackRefs for the whole section so playback flows like
/// it did with the bulk-loaded list. Tracks that haven't hydrated yet
/// are skipped from the play list — typically transient on a cold
/// visit since hydration runs in parallel and finishes quickly.
class _TrackTile extends ConsumerWidget {
const _TrackTile({required this.id, required this.sectionIds});
final String id;
final List<String> sectionIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncTrack = ref.watch(trackTileProvider(id));
final track = asyncTrack.asData?.value;
return AnimatedSwitcher(
duration: _tileRevealDuration,
switchInCurve: Curves.easeOut,
child: track == null
? const _CompactTrackSkeleton(key: ValueKey('skeleton'))
: CompactTrackCard(
key: ValueKey('track-${track.id}'),
track: track,
sectionTracks: _resolveSectionTracks(ref, sectionIds),
index:
sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
),
);
}
/// Snapshots the section's current track states. Used at tile
/// construction time — CompactTrackCard's onTap uses it as the play
/// queue. Tracks still hydrating are dropped; once they land, a
/// rebuild re-runs this lookup so the queue grows naturally.
static List<TrackRef> _resolveSectionTracks(WidgetRef ref, List<String> ids) {
final out = <TrackRef>[];
for (final i in ids) {
final v = ref.read(trackTileProvider(i)).asData?.value;
if (v != null) out.add(v);
}
return out;
}
}
/// Compact-track placeholder. 56dp cover + two text lines, matched to
/// CompactTrackCard so swapping in the real card doesn't shift the
/// row's height.
class _CompactTrackSkeleton extends StatelessWidget {
const _CompactTrackSkeleton({super.key});
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Container(
width: 240,
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(width: 120, height: 12, color: fs.slate),
const SizedBox(height: 6),
Container(width: 80, height: 10, color: fs.slate),
],
),
),
]),
);
}
}
// ─── Sections ────────────────────────────────────────────────────────
class _PlaylistsSection extends StatelessWidget {
const _PlaylistsSection({required this.playlists, required this.status});
final List<Playlist> playlists;
@@ -125,13 +267,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
return null;
}
// Slot 1: For-You.
final forYou = findFirst((p) => p.systemVariant == 'for_you');
out.add(forYou != null
? _RealPlaylist(forYou)
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
// Slots 2-4: Songs-like (real first, padded to 3).
final discover = findFirst((p) => p.systemVariant == 'discover');
out.add(discover != null
? _RealPlaylist(discover)
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
final songsLike = ownedAll
.where((p) => p.systemVariant == 'songs_like_artist')
.take(3)
@@ -142,7 +287,6 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
}
// User-created trail (server returns most-recently-updated first).
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
out.add(_RealPlaylist(p));
}
@@ -158,44 +302,38 @@ String _variantFor(String slot, SystemPlaylistsStatus s) {
}
class _RecentlyAddedSection extends StatelessWidget {
const _RecentlyAddedSection({required this.albums});
final List<AlbumRef> albums;
const _RecentlyAddedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Recently added',
message: "Nothing added yet. Scan a folder via the server's config.",
);
}
final controller = ScrollController();
final rows = _chunk(albums, 25);
final rows = _chunk(ids, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Recently added' : '',
controller: controller,
children: rows[i]
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
children: rows[i].map((id) => _AlbumTile(id: id)).toList(),
),
]);
}
}
class _RediscoverSection extends StatelessWidget {
const _RediscoverSection({required this.albums, required this.artists});
final List<AlbumRef> albums;
final List<ArtistRef> artists;
const _RediscoverSection({required this.albumIds, required this.artistIds});
final List<String> albumIds;
final List<String> artistIds;
@override
Widget build(BuildContext context) {
if (albums.isEmpty && artists.isEmpty) {
if (albumIds.isEmpty && artistIds.isEmpty) {
return const _EmptySection(
title: 'Rediscover',
message:
@@ -203,47 +341,35 @@ class _RediscoverSection extends StatelessWidget {
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (albums.isNotEmpty)
if (albumIds.isNotEmpty)
HorizontalScrollRow(
title: 'Rediscover',
children: albums
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
children: albumIds.map((id) => _AlbumTile(id: id)).toList(),
),
if (artists.isNotEmpty)
if (artistIds.isNotEmpty)
HorizontalScrollRow(
title: albums.isEmpty ? 'Rediscover' : '',
title: albumIds.isEmpty ? 'Rediscover' : '',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
))
.toList(),
children: artistIds.map((id) => _ArtistTile(id: id)).toList(),
),
]);
}
}
class _MostPlayedSection extends StatelessWidget {
const _MostPlayedSection({required this.tracks});
final List<TrackRef> tracks;
const _MostPlayedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (tracks.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Most played',
message: 'No plays to draw from. Listen to something.',
);
}
final controller = ScrollController();
final rows = _chunk(tracks, 25);
final rows = _chunk(ids, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
@@ -251,12 +377,7 @@ class _MostPlayedSection extends StatelessWidget {
height: 64,
controller: controller,
children: [
for (var j = 0; j < rows[i].length; j++)
CompactTrackCard(
track: rows[i][j],
sectionTracks: tracks,
index: i * 25 + j,
),
for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids),
],
),
]);
@@ -264,12 +385,12 @@ class _MostPlayedSection extends StatelessWidget {
}
class _LastPlayedSection extends StatelessWidget {
const _LastPlayedSection({required this.artists});
final List<ArtistRef> artists;
const _LastPlayedSection({required this.ids});
final List<String> ids;
@override
Widget build(BuildContext context) {
if (artists.isEmpty) {
if (ids.isEmpty) {
return const _EmptySection(
title: 'Last played',
message: 'No recent plays.',
@@ -278,12 +399,7 @@ class _LastPlayedSection extends StatelessWidget {
return HorizontalScrollRow(
title: 'Last played',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
))
.toList(),
children: ids.map((id) => _ArtistTile(id: id)).toList(),
);
}
}
@@ -314,8 +430,68 @@ class _EmptySection extends StatelessWidget {
}
}
void _push(BuildContext context, String path) {
context.push(path);
/// Cold-start skeleton — shown only between mount and the first
/// homeIndexProvider emission (typically a single drift query + small
/// REST round-trip). Each section then renders its own per-tile
/// skeletons internally, so this widget's role is just the very first
/// pre-discovery frame.
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: (_, __) => SkeletonAlbumTile(width: cardWidth),
),
),
]);
}
}
List<List<T>> _chunk<T>(List<T> items, int size) {
+318 -26
View File
@@ -1,5 +1,9 @@
import 'dart:async';
import 'dart:convert';
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';
import '../api/client.dart';
@@ -13,6 +17,7 @@ import '../cache/db.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/home_data.dart';
import '../models/home_index.dart';
import '../models/track.dart';
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
@@ -39,10 +44,189 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
final homeProvider = FutureProvider<HomeData>((ref) async {
return (await ref.watch(libraryApiProvider.future)).getHome();
/// Drift-first home data (#357 follow-up). The home screen is the first
/// view the user sees on app open; without a local cache the cold-start
/// blocks on the /api/home round-trip, which is the dominant felt-
/// latency on slow / remote connections.
///
/// Cache layout: a single row in `cached_home_snapshot` storing the
/// last successful HomeData as JSON. On stream subscription:
///
/// - If a row exists, yield it immediately (parsed from JSON) and
/// kick off a background revalidation against /api/home (SWR).
/// The drift watch() re-emits when the new row is written.
/// - If no row exists yet (cold cache), fetch /api/home synchronously,
/// upsert into drift, and emit. Subsequent emissions come from the
/// drift watch as background revalidations land.
///
/// The HomeData JSON encodes back to the same wire shape /api/home
/// emits, so the existing HomeData.fromJson handles both sources.
final homeProvider = StreamProvider<HomeData>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHomeSnapshotData, HomeData>(
driftStream: db.select(db.cachedHomeSnapshot).watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getHome();
await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate(
CachedHomeSnapshotCompanion.insert(
json: _encodeHomeData(fresh),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? const HomeData(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
)
: HomeData.fromJson(jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: when cached snapshot exists, still hit /api/home in the
// background so the user sees the freshest curation. The cache
// mostly serves the first frame, not the canonical state.
alwaysRefresh: true,
tag: 'home',
);
});
/// Drift-first per-item home index. Reads from cached_home_index
/// (populated by /api/home/index discovery) and yields a HomeIndex
/// the screen consumes to lay out section→tile slots. Each tile is
/// rendered by a per-entity tile provider that hydrates itself.
///
/// Section keys mirror the server's response shape so the encode /
/// decode round-trip is straightforward — the table stores
/// (section, position, entityType, entityId), and toResult reassembles
/// the parallel ID lists.
final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedHomeIndex)
..orderBy([
(t) => drift.OrderingTerm.asc(t.section),
(t) => drift.OrderingTerm.asc(t.position),
]))
.watch();
return cacheFirst<CachedHomeIndexData, HomeIndex>(
driftStream: query,
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getHomeIndex();
// Full replace in a transaction so the watch() sees exactly one
// post-fetch emission with the merged state. Section ordering is
// re-asserted on read (orderBy above) so the write order doesn't
// matter — letting us batch flat instead of section-by-section.
await db.transaction(() async {
await db.delete(db.cachedHomeIndex).go();
await db.batch((b) {
void rows(String section, String entityType, List<String> ids) {
for (var i = 0; i < ids.length; i++) {
b.insert(
db.cachedHomeIndex,
CachedHomeIndexCompanion.insert(
section: section,
position: i,
entityType: entityType,
entityId: ids[i],
),
);
}
}
rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums);
rows('rediscover_albums', 'album', fresh.rediscoverAlbums);
rows('rediscover_artists', 'artist', fresh.rediscoverArtists);
rows('most_played_tracks', 'track', fresh.mostPlayedTracks);
rows('last_played_artists', 'artist', fresh.lastPlayedArtists);
});
});
},
toResult: (rows) {
final recentlyAddedAlbums = <String>[];
final rediscoverAlbums = <String>[];
final rediscoverArtists = <String>[];
final mostPlayedTracks = <String>[];
final lastPlayedArtists = <String>[];
for (final r in rows) {
switch (r.section) {
case 'recently_added_albums':
recentlyAddedAlbums.add(r.entityId);
case 'rediscover_albums':
rediscoverAlbums.add(r.entityId);
case 'rediscover_artists':
rediscoverArtists.add(r.entityId);
case 'most_played_tracks':
mostPlayedTracks.add(r.entityId);
case 'last_played_artists':
lastPlayedArtists.add(r.entityId);
}
}
return HomeIndex(
recentlyAddedAlbums: recentlyAddedAlbums,
rediscoverAlbums: rediscoverAlbums,
rediscoverArtists: rediscoverArtists,
mostPlayedTracks: mostPlayedTracks,
lastPlayedArtists: lastPlayedArtists,
);
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: cached layout shows instantly; fresh /api/home/index lands
// in the background and tiles re-resolve as the table mutates.
alwaysRefresh: true,
tag: 'homeIndex',
);
});
/// Encodes HomeData back to the wire-format JSON shape /api/home
/// emits, so HomeData.fromJson can round-trip through the drift cache.
String _encodeHomeData(HomeData h) => jsonEncode({
'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(),
'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(),
'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(),
'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(),
'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(),
});
Map<String, dynamic> _albumToJson(AlbumRef a) => {
'id': a.id,
'title': a.title,
'sort_title': a.sortTitle,
'artist_id': a.artistId,
'artist_name': a.artistName,
'year': a.year,
'track_count': a.trackCount,
'duration_sec': a.durationSec,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _artistToJson(ArtistRef a) => {
'id': a.id,
'name': a.name,
'sort_name': a.sortName,
'album_count': a.albumCount,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _trackToJson(TrackRef t) => {
'id': t.id,
'title': t.title,
'album_id': t.albumId,
'album_title': t.albumTitle,
'artist_id': t.artistId,
'artist_name': t.artistName,
'track_number': t.trackNumber,
'disc_number': t.discNumber,
'duration_sec': t.durationSec,
'stream_url': t.streamUrl,
};
/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
@@ -60,8 +244,14 @@ final artistProvider =
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
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)',
);
});
@@ -89,8 +279,18 @@ final artistAlbumsProvider =
final artist = r.readTableOrNull(db.cachedArtists);
return album.toRef(artistName: artist?.name ?? '');
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
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)',
);
});
@@ -123,8 +323,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)',
);
});
@@ -152,31 +354,97 @@ final albumProvider = StreamProvider.family<
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
]);
await for (final albumRows in albumQuery.watch()) {
if (albumRows.isEmpty) {
// Cold cache fallback
if (await ref.read(connectivityProvider.future)) {
try {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getAlbum(albumId);
await db.batch((b) {
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
// watch() re-emits with the populated rows; loop continues.
} catch (_) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
// 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 {
return await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (_) {
return true;
}
}
// Cold-cache fetch: pulls the album + its tracks + any unique
// artists referenced and writes all three tables in one batch.
// Returns true on success (drift watch will re-emit), false on
// failure so the caller can yield an empty result.
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
// 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
// for the artist row and artistName surfaces empty — visible
// as a missing artist line in the mini player and row list.
final artists = <String, ArtistRef>{};
if (fresh.album.artistId.isNotEmpty &&
fresh.album.artistName.isNotEmpty) {
artists[fresh.album.artistId] = ArtistRef(
id: fresh.album.artistId,
name: fresh.album.artistName,
);
}
for (final t in fresh.tracks) {
if (t.artistId.isNotEmpty &&
t.artistName.isNotEmpty &&
!artists.containsKey(t.artistId)) {
artists[t.artistId] = ArtistRef(
id: t.artistId,
name: t.artistName,
);
}
} else {
}
await db.batch((b) {
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
}
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
return true;
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
return false;
}
}
await for (final albumRows in albumQuery.watch()) {
// Case 1: no album row at all → cold-fetch.
if (albumRows.isEmpty) {
if (fetchAttempted) {
// Already tried and got nothing back.
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
continue;
}
final ok = await fetchAndPopulate();
if (!ok) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
// On success, drift watch re-emits with rows; loop continues.
continue;
}
@@ -186,6 +454,22 @@ final albumProvider = StreamProvider.family<
);
final trackRows = await tracksQuery.get();
// Case 2: album row exists but no tracks. This happens when an
// upstream provider (artistAlbumsProvider, sync, etc.) populated
// album rows without their track lists. Trigger the same
// 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) {
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
if (ok) continue; // wait for watch re-emit
}
// Fall through and yield with the album + empty tracks if the
// fetch failed or we're offline.
}
final tracks = trackRows.map((r) {
final track = r.readTable(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
@@ -195,6 +479,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);
}
});
+557 -96
View File
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -5,6 +8,13 @@ import 'package:go_router/go_router.dart';
import '../api/endpoints/library_lists.dart';
import '../api/endpoints/likes.dart';
import '../api/endpoints/me.dart';
import '../auth/auth_provider.dart' show authControllerProvider;
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../cache/metadata_prefetcher.dart';
import '../cache/tile_providers.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/album.dart';
import '../models/artist.dart';
@@ -15,7 +25,11 @@ import '../models/page.dart' as wire;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../quarantine/quarantine_provider.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../shared/widgets/skeletons.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
@@ -37,33 +51,271 @@ 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;
@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);
// Drift-first History tab. Mirrors homeProvider's pattern: store the
// last /api/me/history page as JSON in a single-row drift table, yield
// it immediately on subscribe (so the tab paints from disk on cold
// open), then SWR-refresh in the background. Also gives basic offline
// scrollback — the last fetched page survives connectivity loss.
//
// JSON blob (vs columnar) because the page is small, always read whole,
// and the HistoryPage.fromJson constructor already accepts the wire
// shape — no schema-evolution pain when server-side fields change.
final _historyProvider = StreamProvider<HistoryPage>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
driftStream: db.select(db.cachedHistorySnapshot).watch(),
fetchAndPopulate: () async {
final api = await ref.read(_meApiProvider.future);
final fresh = await api.history();
await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate(
CachedHistorySnapshotCompanion.insert(
json: _encodeHistoryPage(fresh),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? const HistoryPage(events: [], hasMore: false)
: HistoryPage.fromJson(
jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: yield cache instantly, then refresh in the background so the
// tab reflects the freshest plays. Matches homeProvider behavior.
alwaysRefresh: true,
tag: 'history',
);
});
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
/// Encodes HistoryPage back to the wire-format JSON shape
/// /api/me/history emits, so HistoryPage.fromJson can round-trip
/// through the drift cache.
String _encodeHistoryPage(HistoryPage h) => jsonEncode({
'events': h.events
.map((e) => {
'id': e.id,
'played_at': e.playedAt,
'track': {
'id': e.track.id,
'title': e.track.title,
'album_id': e.track.albumId,
'album_title': e.track.albumTitle,
'artist_id': e.track.artistId,
'artist_name': e.track.artistName,
'track_number': e.track.trackNumber,
'disc_number': e.track.discNumber,
'duration_sec': e.track.durationSec,
'stream_url': e.track.streamUrl,
},
})
.toList(),
'has_more': h.hasMore,
});
// Per-item Liked tabs (Slice E of the per-item rendering pass).
// Each provider yields just the ordered list of entity IDs; the UI
// then renders per-tile widgets that hydrate each entity individually
// via albumTileProvider / artistTileProvider / trackTileProvider.
//
// Reads come from cached_likes (sync- and optimistic-write-populated),
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
// that returned fully denormalized entities are no longer needed for
// this path since tile providers handle entity hydration themselves.
//
// likedAt ordering note: cached_likes.likedAt is whatever drift
// assigned via currentDateAndTime when the row was first inserted
// (either by sync or LikesController). insertOrIgnore on subsequent
// fetches preserves the existing likedAt so ordering stays stable.
// Approximate but acceptable — matches prior Slice 3 behavior.
List<String> _idsForEntity(List<CachedLike> rows) =>
rows.map((r) => r.entityId).toList(growable: false);
final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedLikes)
..where((t) => t.entityType.equals('track'))
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
.watch();
return cacheFirst<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedTrackIds',
);
});
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedLikes)
..where((t) => t.entityType.equals('album'))
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
.watch();
return cacheFirst<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedAlbumIds',
);
});
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listTracks();
final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
final db = ref.watch(appDbProvider);
final query = (db.select(db.cachedLikes)
..where((t) => t.entityType.equals('artist'))
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
.watch();
return cacheFirst<CachedLike, List<String>>(
driftStream: query,
fetchAndPopulate: () => _populateLikeIds(ref),
toResult: _idsForEntity,
isOnline: () async => (await ref.read(connectivityProvider.future)),
alwaysRefresh: true,
tag: 'likedArtistIds',
);
});
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listAlbums();
});
/// Shared cold-cache populator: hits /api/likes/ids once and writes
/// rows for all three entity types via insertOrIgnore. The three
/// providers above all trigger this on their first empty-drift
/// emission; the dedup in cacheFirst's revalidate state plus drift's
/// insertOrIgnore semantics make the multiple-trigger case cheap.
Future<void> _populateLikeIds(Ref ref) async {
final api = await ref.read(_likesApiProvider.future);
final user = ref.read(authControllerProvider).value;
if (user == null) return;
final fresh = await api.ids();
final db = ref.read(appDbProvider);
await db.batch((b) {
for (final id in fresh.tracks) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'track',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.albums) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'album',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.artists) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'artist',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
});
}
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listArtists();
});
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
return (await ref.watch(_meApiProvider.future)).quarantineMine();
});
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
// optimistic flag/unflag) so flagging from any kebab and unflagging from
// the Hidden tab keep one source of truth.
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@@ -131,25 +383,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 +443,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),
);
},
),
);
}),
);
},
);
}
}
@@ -227,71 +548,82 @@ class _LikedTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final tracksA = ref.watch(_likedTracksProvider);
final albumsA = ref.watch(_likedAlbumsProvider);
final artistsA = ref.watch(_likedArtistsProvider);
// SSE wire-up: any cross-device like / unlike triggers a refresh
// of the discovery providers. LikesController handles local
// mutations optimistically through the same cached_likes table,
// so toggling a like locally re-emits the streams instantly
// without needing an invalidate here.
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e == null) return;
switch (e.kind) {
case 'track.liked':
case 'track.unliked':
case 'album.liked':
case 'album.unliked':
case 'artist.liked':
case 'artist.unliked':
ref.invalidate(_likedTrackIdsProvider);
ref.invalidate(_likedAlbumIdsProvider);
ref.invalidate(_likedArtistIdsProvider);
}
});
final tracksA = ref.watch(_likedTrackIdsProvider);
final albumsA = ref.watch(_likedAlbumIdsProvider);
final artistsA = ref.watch(_likedArtistIdsProvider);
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final t = tracksA.value;
final al = albumsA.value;
final ar = artistsA.value;
if (t == null || al == null || ar == null) {
final trackIds = tracksA.value;
final albumIds = albumsA.value;
final artistIds = artistsA.value;
if (trackIds == null || albumIds == null || artistIds == null) {
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
}
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) {
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
}
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
ref.refresh(_likedTracksProvider.future),
ref.refresh(_likedAlbumsProvider.future),
ref.refresh(_likedArtistsProvider.future),
ref.refresh(_likedTrackIdsProvider.future),
ref.refresh(_likedAlbumIdsProvider.future),
ref.refresh(_likedArtistIdsProvider.future),
]);
},
child: ListView(children: [
if (ar.items.isNotEmpty) ...[
_SectionHeader(label: 'Artists', count: ar.total),
if (artistIds.isNotEmpty) ...[
_SectionHeader(label: 'Artists', count: artistIds.length),
SizedBox(
height: 168,
child: ListView.builder(
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}'),
),
itemCount: artistIds.length,
itemBuilder: (ctx, i) =>
_LikedArtistTile(id: artistIds[i]),
),
),
],
if (al.items.isNotEmpty) ...[
_SectionHeader(label: 'Albums', count: al.total),
if (albumIds.isNotEmpty) ...[
_SectionHeader(label: 'Albums', count: albumIds.length),
SizedBox(
height: 200,
child: ListView.builder(
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}'),
),
itemCount: albumIds.length,
itemBuilder: (ctx, i) =>
_LikedAlbumTile(id: albumIds[i]),
),
),
],
if (t.items.isNotEmpty) ...[
_SectionHeader(label: 'Tracks', count: t.total),
...t.items.asMap().entries.map((e) {
return TrackRow(
track: e.value,
onTap: () => ref
.read(playerActionsProvider)
.playTracks(t.items, initialIndex: e.key),
);
}),
if (trackIds.isNotEmpty) ...[
_SectionHeader(label: 'Tracks', count: trackIds.length),
...trackIds.map(
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
),
],
const SizedBox(height: 96),
]),
@@ -299,13 +631,102 @@ class _LikedTab extends ConsumerWidget {
}
}
/// Skeleton→content cross-fade duration. Matches home_screen so the
/// reveal feel is consistent across surfaces. See _tileRevealDuration
/// in home_screen.dart for the rationale.
const Duration _likedTileReveal = Duration(milliseconds: 220);
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
/// yields a populated row.
class _LikedArtistTile extends ConsumerWidget {
const _LikedArtistTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(artistTileProvider(id)).asData?.value;
return AnimatedSwitcher(
duration: _likedTileReveal,
switchInCurve: Curves.easeOut,
child: artist == null
? const SkeletonArtistTile(key: ValueKey('skeleton'))
: ArtistCard(
key: ValueKey('artist-${artist.id}'),
artist: artist,
onTap: () =>
context.push('/artists/${artist.id}', extra: artist),
),
);
}
}
/// Liked-Albums carousel tile. Skeleton until albumTileProvider
/// yields a populated row.
class _LikedAlbumTile extends ConsumerWidget {
const _LikedAlbumTile({required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumTileProvider(id)).asData?.value;
return AnimatedSwitcher(
duration: _likedTileReveal,
switchInCurve: Curves.easeOut,
child: album == null
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
: AlbumCard(
key: ValueKey('album-${album.id}'),
album: album,
onTap: () => context.push('/albums/${album.id}', extra: album),
),
);
}
}
/// Liked-Tracks list row. Skeleton until trackTileProvider yields a
/// populated row. Tap plays the section starting at this track,
/// using whichever tracks are currently hydrated; still-loading
/// tracks are skipped from the play queue and join on next rebuild.
class _LikedTrackRow extends ConsumerWidget {
const _LikedTrackRow({required this.id, required this.sectionIds});
final String id;
final List<String> sectionIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
final track = ref.watch(trackTileProvider(id)).asData?.value;
return AnimatedSwitcher(
duration: _likedTileReveal,
switchInCurve: Curves.easeOut,
child: track == null
? const SkeletonTrackRow(key: ValueKey('skeleton'))
: TrackRow(
key: ValueKey('track-${track.id}'),
track: track,
onTap: () {
final hydrated = <TrackRef>[];
for (final i in sectionIds) {
final v = ref.read(trackTileProvider(i)).asData?.value;
if (v != null) hydrated.add(v);
}
final start = hydrated.indexWhere((t) => t.id == id);
ref.read(playerActionsProvider).playTracks(
hydrated,
initialIndex: start < 0 ? 0 : start,
);
},
),
);
}
}
class _HiddenTab extends ConsumerWidget {
const _HiddenTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ref.watch(_quarantineProvider).when(
return ref.watch(myQuarantineProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) => rows.isEmpty
@@ -317,7 +738,7 @@ class _HiddenTab extends ConsumerWidget {
),
)
: RefreshIndicator(
onRefresh: () async => ref.refresh(_quarantineProvider.future),
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
child: ListView.separated(
itemCount: rows.length,
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
@@ -328,12 +749,12 @@ class _HiddenTab extends ConsumerWidget {
}
}
class _QuarantineTile extends StatelessWidget {
class _QuarantineTile extends ConsumerWidget {
const _QuarantineTile({required this.row});
final QuarantineMineRow row;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final reasonLabel = switch (row.reason) {
'bad_rip' => 'Bad rip',
@@ -342,9 +763,25 @@ class _QuarantineTile extends StatelessWidget {
'duplicate' => 'Duplicate',
_ => 'Other',
};
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 56,
height: 56,
child: coverUrl.isEmpty
? Container(color: fs.slate)
: ServerImage(
url: coverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
@@ -361,17 +798,41 @@ class _QuarantineTile extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
),
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
),
if (row.createdAt.isNotEmpty) ...[
const SizedBox(width: 8),
Text(_relativeTime(row.createdAt),
style: TextStyle(color: fs.ash, fontSize: 11)),
],
]),
),
]),
),
IconButton(
tooltip: 'Unhide',
icon: Icon(Icons.restore, color: fs.ash, size: 20),
onPressed: () async {
try {
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
} catch (_) {
// Optimistic rollback handled by the notifier; surface
// the failure only if the user kept the tab open.
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not unhide; try again.')),
);
}
}
},
),
]),
);
}
@@ -1,52 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/album.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
import '../library_providers.dart';
import 'play_circle_button.dart';
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, required this.onTap, super.key});
class AlbumCard extends ConsumerWidget {
const AlbumCard({
required this.album,
required this.onTap,
this.width = 140,
this.titleMaxLines = 1,
this.showArtist = true,
super.key,
});
final AlbumRef album;
final VoidCallback onTap;
/// Outer width of the card. Cover is square at width - 16 (8dp
/// horizontal padding either side). Default suits horizontal lists;
/// grids should pass the cell width so the card scales down.
final double width;
/// Lets callers (e.g. the artist detail grid) allow the title to
/// wrap to a second line so it isn't truncated to a single
/// ellipsized character at narrower widths.
final int titleMaxLines;
/// Suppress the artist name. Useful on surfaces where the artist is
/// already implied by the page header (e.g. artist detail).
final bool showArtist;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
),
final coverSize = width - 16;
return SizedBox(
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Stack: cover image + overlaid play button at bottom-right.
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: coverSize,
height: coverSize,
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
),
),
Positioned(
bottom: 6,
right: 6,
child: PlayCircleButton(
onPressed: () => _playAlbum(ref),
),
),
],
),
const SizedBox(height: 8),
Text(
album.title,
maxLines: titleMaxLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (showArtist && album.artistName.isNotEmpty)
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
const SizedBox(height: 8),
Text(
album.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
),
),
);
}
/// Fetches the album's tracks via /api/albums/{id} and starts playback
/// from the first track. Errors are swallowed by the button's outer
/// try/finally; callers don't surface them — failed fetches just keep
/// the spinner visible until the button is retapped.
Future<void> _playAlbum(WidgetRef ref) async {
final api = await ref.read(libraryApiProvider.future);
final result = await api.getAlbum(album.id);
if (result.tracks.isEmpty) return;
await ref
.read(playerActionsProvider)
.playTracks(result.tracks, initialIndex: 0);
}
}
@@ -1,45 +1,84 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../models/artist.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
import '../library_providers.dart';
import 'play_circle_button.dart';
class ArtistCard extends StatelessWidget {
class ArtistCard extends ConsumerWidget {
const ArtistCard({required this.artist, required this.onTap, super.key});
final ArtistRef artist;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipOval(
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
return SizedBox(
width: 140,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Stack: circular avatar + overlaid play button at bottom-right.
// The avatar is a circle (ClipOval) — bottom-right of its
// bounding box still places the button inside the visible area
// because the button is small relative to the radius.
Stack(
children: [
ClipOval(
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
),
),
Positioned(
bottom: 4,
right: 4,
child: PlayCircleButton(
onPressed: () => _playArtistShuffle(ref),
),
),
],
),
),
const SizedBox(height: 8),
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
]),
const SizedBox(height: 8),
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
]),
),
),
),
);
}
/// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles
/// them (Fisher-Yates, default Random), and plays from index 0.
/// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`.
Future<void> _playArtistShuffle(WidgetRef ref) async {
final api = await ref.read(libraryApiProvider.future);
final tracks = await api.getArtistTracks(artist.id);
if (tracks.isEmpty) return;
final shuffled = List.of(tracks);
shuffled.shuffle(Random());
await ref
.read(playerActionsProvider)
.playTracks(shuffled, initialIndex: 0);
}
}
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Always-visible 44dp circular play button overlaid on home-screen
/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the
/// hover-revealed `.play-overlay` on the web cards, but always shown
/// because hover is not a real interaction on touch.
///
/// Manages its own loading state via [_starting] so the caller's tap
/// handler can be `Future<void> Function()` without worrying about
/// re-entrancy. Disabled state suppresses the tap (used for empty
/// playlists).
class PlayCircleButton extends StatefulWidget {
const PlayCircleButton({
required this.onPressed,
this.enabled = true,
this.size = 44,
super.key,
});
/// Tap handler. Returns a Future so the button can show a spinner
/// during the play setup (fetch detail tracks, etc.).
final Future<void> Function() onPressed;
/// When false, the button is rendered semi-transparent and taps are
/// ignored. Empty playlists / artists with no tracks should disable.
final bool enabled;
/// Outer diameter in logical pixels. 44 is the iOS / Android
/// touch-target minimum; matches the design doc.
final double size;
@override
State<PlayCircleButton> createState() => _PlayCircleButtonState();
}
class _PlayCircleButtonState extends State<PlayCircleButton> {
bool _starting = false;
Future<void> _handleTap() async {
if (_starting || !widget.enabled) return;
setState(() => _starting = true);
try {
await widget.onPressed();
} finally {
if (mounted) setState(() => _starting = false);
}
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final iconSize = widget.size * 0.5;
return Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkResponse(
onTap: widget.enabled ? _handleTap : null,
radius: widget.size / 2,
containedInkWell: true,
customBorder: const CircleBorder(),
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5),
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: _starting
? SizedBox(
width: iconSize,
height: iconSize,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(fs.parchment),
),
)
: Icon(
Icons.play_arrow,
color: fs.parchment,
size: iconSize,
),
),
),
);
}
}
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/track.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/track_actions/track_actions_button.dart';
import '../../theme/theme_extension.dart';
import 'cached_indicator.dart';
class TrackRow extends StatelessWidget {
class TrackRow extends ConsumerWidget {
const TrackRow({
required this.track,
required this.onTap,
@@ -23,48 +25,75 @@ class TrackRow extends StatelessWidget {
final bool actions;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
if (track.trackNumber != null)
SizedBox(
width: 28,
child: Text(
track.trackNumber.toString(),
style: TextStyle(color: fs.ash, fontSize: 13),
// Watch the currently-playing media item so the row's accent
// highlight tracks playback state. Matches _QueueRow's visual
// treatment in queue_screen.dart so the "you are here" cue is
// consistent across album / playlist / queue surfaces.
final currentId = ref.watch(mediaItemProvider).value?.id;
final isCurrent = currentId != null && currentId == track.id;
return Container(
decoration: BoxDecoration(
color: isCurrent ? fs.iron : null,
border: isCurrent
? Border(left: BorderSide(color: fs.accent, width: 2))
: null,
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(children: [
if (track.trackNumber != null)
SizedBox(
width: 22,
child: Text(
track.trackNumber.toString(),
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? fs.accent : fs.parchment,
fontSize: 14,
fontWeight:
isCurrent ? FontWeight.w500 : FontWeight.w400,
),
),
// Skip the artist line entirely when empty so the row
// height collapses to a single line of title — keeps
// dense album views from looking padded.
if (track.artistName.isNotEmpty)
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
CachedIndicator(trackId: track.id),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (trailing != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: trailing!,
),
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
CachedIndicator(trackId: track.id),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (trailing != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: trailing!,
),
if (actions) TrackActionsButton(track: track),
]),
if (actions) TrackActionsButton(track: track),
]),
),
),
);
}
+41
View File
@@ -0,0 +1,41 @@
/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of
/// entity ID strings — the per-item rendering variant of HomeData.
/// Section name implies entity type; no per-entry type tag is needed.
///
/// Slices are non-null after fromJson so callers can branch on `length`
/// instead of dealing with null sections.
class HomeIndex {
const HomeIndex({
required this.recentlyAddedAlbums,
required this.rediscoverAlbums,
required this.rediscoverArtists,
required this.mostPlayedTracks,
required this.lastPlayedArtists,
});
final List<String> recentlyAddedAlbums;
final List<String> rediscoverAlbums;
final List<String> rediscoverArtists;
final List<String> mostPlayedTracks;
final List<String> lastPlayedArtists;
static const empty = HomeIndex(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
factory HomeIndex.fromJson(Map<String, dynamic> j) {
List<String> ids(String key) =>
((j[key] as List?) ?? const []).map((e) => e.toString()).toList();
return HomeIndex(
recentlyAddedAlbums: ids('recently_added_albums'),
rediscoverAlbums: ids('rediscover_albums'),
rediscoverArtists: ids('rediscover_artists'),
mostPlayedTracks: ids('most_played_tracks'),
lastPlayedArtists: ids('last_played_artists'),
);
}
}
@@ -0,0 +1,83 @@
// Dominant-color extraction from album cover art (#396 item 1).
// The full-screen Now Playing screen uses this to paint a top-to-bottom
// gradient backdrop that grounds each track in its album's palette.
//
// Implementation: reuses AlbumCoverCache to get a local file path for
// the cover, then runs PaletteGenerator over it. Results are cached
// in-memory keyed by album_id so back-to-back plays of the same album
// don't repeat the work. Cache is process-lifetime; album-art changes
// are rare enough that LRU eviction isn't worth the complexity.
//
// Returns null on any failure (no cover, empty album_id, palette
// extraction returned nothing). Callers fall back to the FabledSword
// obsidian color for the gradient when null.
import 'dart:io';
import 'package:flutter/painting.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'player_provider.dart';
/// Caches dominant colors keyed by album_id. Process-lifetime; refilled
/// on app restart.
class AlbumColorCache {
AlbumColorCache(this._ref);
final Ref _ref;
final Map<String, Color?> _cache = {};
/// Returns the dominant color for the album's cover, or null if no
/// cover is available or extraction fails. Same call for the same
/// album_id is cached after the first successful resolution.
Future<Color?> getOrExtract(String albumId) async {
if (albumId.isEmpty) return null;
if (_cache.containsKey(albumId)) return _cache[albumId];
final color = await _extract(albumId);
_cache[albumId] = color;
return color;
}
Future<Color?> _extract(String albumId) async {
try {
final coverCache = _ref.read(albumCoverCacheProvider);
final path = await coverCache.getOrFetch(albumId);
if (path == null) return null;
final file = File(path);
if (!await file.exists()) return null;
final palette = await PaletteGenerator.fromImageProvider(
FileImage(file),
// Small target size: palette extraction is CPU-bound and the
// gradient only needs a single dominant color, so we don't
// need full-resolution sampling.
size: const Size(80, 80),
maximumColorCount: 8,
);
// Prefer the explicit dominant color; fall back to the strongest
// muted swatch (which tends to read better as a background than
// a hot vibrant pick), then any populated swatch.
final swatch = palette.dominantColor ??
palette.darkMutedColor ??
palette.darkVibrantColor ??
palette.mutedColor;
return swatch?.color;
} catch (_) {
return null;
}
}
}
final albumColorCacheProvider = Provider<AlbumColorCache>(
(ref) => AlbumColorCache(ref),
);
/// Family provider: dominant color for the given album_id, or null if
/// no cover / extraction failed. The Now Playing screen watches this
/// keyed by the current MediaItem's album_id.
final albumColorProvider = FutureProvider.family<Color?, String>(
(ref, albumId) async {
final cache = ref.watch(albumColorCacheProvider);
return cache.getOrExtract(albumId);
},
);
+243 -37
View File
@@ -12,8 +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();
@@ -22,6 +41,46 @@ 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;
double get volume => _player.volume;
/// Position stream for UI subscribers. just_audio emits roughly every
/// 200ms while playing, which is what makes the seek bar advance
/// smoothly. PlaybackState.updatePosition only changes on event
/// transitions (play/pause/buffer), so it's too coarse for live
/// scrubbing UI.
Stream<Duration> get positionStream => _player.positionStream;
Duration get position => _player.position;
void configure({
required String baseUrl,
required String? token,
@@ -32,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) {
@@ -86,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));
}
}
@@ -110,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);
}
@@ -148,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());
}
@@ -198,7 +356,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
void _broadcastState(PlaybackEvent event) {
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _player
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
// _broadcastState picks up the change via shuffleModeEnabledStream.
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
final loop = switch (repeatMode) {
AudioServiceRepeatMode.none => LoopMode.off,
AudioServiceRepeatMode.one => LoopMode.one,
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
};
await _player.setLoopMode(loop);
}
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
/// page audio to system volume — this is the in-app slider for parity
/// with desktop/web; on mobile the system volume is the real control.
Future<void> setVolume(double v) async {
await _player.setVolume(v.clamp(0.0, 1.0));
}
// _broadcastState accepts a nullable event because the shuffle/repeat
// listeners don't have one — we just want to re-emit PlaybackState
// with up-to-date shuffleMode/repeatMode fields.
void _broadcastState(PlaybackEvent? event) {
final playing = _player.playing;
playbackState.add(PlaybackState(
controls: [
@@ -206,7 +391,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {MediaAction.seek},
// androidCompactActionIndices tells the system which controls
// appear in the collapsed/lock-screen view. Without this, some
// Android versions render the player without working buttons.
androidCompactActionIndices: const [0, 1, 2],
// systemActions enumerates which actions the system can invoke
// on us — without play/pause/skip in here, taps on lock-screen
// controls don't route back to the handler on Android 13+.
systemActions: const {
MediaAction.play,
MediaAction.pause,
MediaAction.skipToNext,
MediaAction.skipToPrevious,
MediaAction.seek,
},
processingState: switch (_player.processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
@@ -218,7 +416,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: event.currentIndex,
queueIndex: event?.currentIndex ?? _player.currentIndex,
shuffleMode: _player.shuffleModeEnabled
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
repeatMode: switch (_player.loopMode) {
LoopMode.off => AudioServiceRepeatMode.none,
LoopMode.one => AudioServiceRepeatMode.one,
LoopMode.all => AudioServiceRepeatMode.all,
},
));
}
}
+449 -74
View File
@@ -1,17 +1,71 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/endpoints/likes.dart' show LikeKind;
import '../likes/like_button.dart';
import '../models/track.dart';
import '../shared/widgets/server_image.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'album_color_extractor.dart';
import 'player_provider.dart';
class NowPlayingScreen extends ConsumerWidget {
/// Hero tag shared between the mini player's cover and the full-screen
/// _AlbumArt's cover so tapping the mini bar animates the artwork from
/// the bar's footprint to the full-screen size. Stable per-route (not
/// keyed by media.id) so the transition works regardless of what's
/// playing.
const String kPlayerCoverHeroTag = 'player-cover';
/// Duration for the AnimatedSwitcher / AnimatedContainer track-change
/// crossfade. ~300ms reads as a smooth transition without dragging.
const Duration _trackChangeDuration = Duration(milliseconds: 300);
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
/// transition (see routing.dart). Dismisses three ways:
///
/// - System back / leading button → Navigator.pop
/// - Vertical drag down past threshold → Navigator.pop (matches the
/// slide-down animation users expect from a "pull down to close"
/// sheet)
/// - Tap of the mini player ascends here; reverse motion descends back.
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// Cumulative drag distance used to decide if a vertical drag should
// dismiss. Reset on each drag start.
double _dragOffset = 0;
void _onDragStart(DragStartDetails _) {
_dragOffset = 0;
}
void _onDragUpdate(DragUpdateDetails d) {
_dragOffset += d.delta.dy;
}
void _onDragEnd(DragEndDetails d) {
// Pop when either the user has dragged > 80px down OR flicked
// downward at speed (>500 px/s). Either should feel responsive
// without dismissing on accidental drags.
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
if (_dragOffset > 80 || flicked) {
Navigator.of(context).maybePop();
}
_dragOffset = 0;
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
@@ -20,87 +74,408 @@ class NowPlayingScreen extends ConsumerWidget {
return const Scaffold(body: Center(child: Text('Nothing playing.')));
}
final pos = playback?.updatePosition ?? Duration.zero;
// Use positionProvider (just_audio's positionStream, ~200ms) for
// the seek bar so it scrubs live; PlaybackState.updatePosition
// only fires on state transitions and would leave the bar frozen
// between them.
final pos = ref.watch(positionProvider).value ?? Duration.zero;
final dur = media.duration ?? Duration.zero;
final isPlaying = playback?.playing == true;
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
final actions = ref.read(playerActionsProvider);
final albumId = (media.extras?['album_id'] as String?) ?? '';
// Dominant-color gradient backdrop seeded from the current track's
// album cover. While the color resolves (or when no cover exists),
// the gradient collapses to a flat fs.obsidian — same look as
// before the polish landed. Tweens to the new color on track change
// via AnimatedContainer.
final dominantAsync = ref.watch(albumColorProvider(albumId));
final dominant = dominantAsync.asData?.value ?? fs.obsidian;
// 0.55 alpha keeps the color present without overwhelming contrast
// on the title / artist text below. The lower half of the screen
// stays obsidian for control legibility.
final gradientTop = dominant.withValues(alpha: 0.55);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
leading: IconButton(
icon: Icon(Icons.expand_more, color: fs.parchment),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
tooltip: 'Queue',
onPressed: () => GoRouter.of(context).push('/queue'),
// The whole screen accepts vertical drag for dismissal. Buttons
// and the seek slider are still tappable since gesture arena
// gives priority to their child gesture detectors.
body: GestureDetector(
onVerticalDragStart: _onDragStart,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
behavior: HitTestBehavior.translucent,
child: AnimatedContainer(
duration: _trackChangeDuration,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [gradientTop, fs.obsidian],
stops: const [0.0, 0.7],
),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
const Spacer(),
Container(
width: 280, height: 280, color: fs.slate,
),
const SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Flexible(
child: Text(
media.title,
style: TextStyle(color: fs.parchment, fontSize: 22),
overflow: TextOverflow.ellipsis,
child: SafeArea(
child: Column(
children: [
_TopBar(fs: fs),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Spacer(),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('cover-${media.id}'),
child: _AlbumArt(
media: media, albumId: albumId, fs: fs),
),
),
const SizedBox(height: 28),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('title-${media.id}'),
child: _TitleRow(media: media, fs: fs),
),
),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: Column(
key: ValueKey('artist-album-${media.id}'),
mainAxisSize: MainAxisSize.min,
children: [
Text(
media.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if ((media.album ?? '').isNotEmpty) ...[
const SizedBox(height: 2),
Text(
media.album!,
style: TextStyle(color: fs.ash, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
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),
],
),
),
),
TrackActionsButton(
track: TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
artistId: '', // not stashed in MediaItem.extras today
artistName: media.artist ?? '',
durationSec: media.duration?.inSeconds ?? 0,
streamUrl: '',
),
hideQueueActions: true,
),
]),
Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)),
const SizedBox(height: 16),
Slider(
activeColor: fs.accent,
min: 0,
max: dur.inMilliseconds.toDouble().clamp(1, double.infinity),
value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()),
onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())),
),
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
IconButton(
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
IconButton(
iconSize: 56,
icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (playback?.playing == true) { h.pause(); } else { h.play(); }
},
),
IconButton(
icon: Icon(Icons.skip_next, color: fs.parchment, size: 32),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
]),
const Spacer(),
]),
],
),
),
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.fs});
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(children: [
IconButton(
icon: Icon(Icons.expand_more, color: fs.parchment),
tooltip: 'Close',
onPressed: () => Navigator.of(context).maybePop(),
),
const Spacer(),
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
tooltip: 'Queue',
onPressed: () => GoRouter.of(context).push('/queue'),
),
]),
),
);
}
}
class _AlbumArt extends StatelessWidget {
const _AlbumArt({required this.media, required this.albumId, required this.fs});
final MediaItem media;
final String albumId;
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
// Pick the best available source. AlbumCoverCache writes a file://
// URI to media.artUri once the cover lands on disk; before then,
// fall back to fetching via ServerImage from /api/albums/<id>/cover
// (which carries the auth header and falls back gracefully on
// failure).
Widget cover;
final artUri = media.artUri;
if (artUri != null && artUri.isScheme('file')) {
cover = Image(
image: FileImage(File.fromUri(artUri)),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: fs.slate),
);
} else if (albumId.isNotEmpty) {
cover = ServerImage(
url: '/api/albums/$albumId/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
);
} else {
cover = Container(color: fs.slate);
}
return AspectRatio(
aspectRatio: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
child: Hero(
tag: kPlayerCoverHeroTag,
// flightShuttleBuilder ensures the in-flight Hero renders the
// destination's cover image (not the mini bar's small one)
// during the entire animation, which reads as a smooth grow
// rather than a swap mid-flight.
flightShuttleBuilder:
(_, __, ___, ____, toHeroContext) => toHeroContext.widget,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
),
),
),
);
}
}
class _TitleRow extends StatelessWidget {
const _TitleRow({required this.media, required this.fs});
final MediaItem media;
final FabledSwordTheme fs;
@override
Widget build(BuildContext context) {
// 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,
);
}
}
class _SeekRow extends StatelessWidget {
const _SeekRow({
required this.position,
required this.duration,
required this.fs,
required this.ref,
});
final Duration position;
final Duration duration;
final FabledSwordTheme fs;
final WidgetRef ref;
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
return Column(children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
activeColor: fs.accent,
inactiveColor: fs.slate,
min: 0,
max: maxMs,
value: position.inMilliseconds
.toDouble()
.clamp(0.0, duration.inMilliseconds.toDouble()),
onChanged: (v) => ref
.read(audioHandlerProvider)
.seek(Duration(milliseconds: v.toInt())),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
],
),
),
]);
}
}
class _PrimaryControls extends StatelessWidget {
const _PrimaryControls({
required this.fs,
required this.ref,
required this.isPlaying,
});
final FabledSwordTheme fs;
final WidgetRef ref;
final bool isPlaying;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_previous, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
IconButton(
iconSize: 72,
icon: Icon(
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
color: fs.accent,
),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (isPlaying) {
h.pause();
} else {
h.play();
}
},
),
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_next, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
],
);
}
}
/// 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) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
icon: Icon(
Icons.shuffle,
color: shuffleOn ? fs.accent : fs.ash,
),
onPressed: actions.toggleShuffle,
),
IconButton(
tooltip: switch (repeatMode) {
AudioServiceRepeatMode.none => 'Repeat off',
AudioServiceRepeatMode.one => 'Repeat one',
_ => 'Repeat all',
},
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
color: repeatMode == AudioServiceRepeatMode.none
? fs.ash
: fs.accent,
),
onPressed: actions.cycleRepeat,
),
IconButton(
tooltip: 'Queue',
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);
},
),
],
);
}
}
+288 -28
View File
@@ -1,53 +1,313 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/endpoints/likes.dart' show LikeKind;
import '../likes/like_button.dart';
import '../models/track.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
import '../theme/theme_extension.dart';
import 'now_playing_screen.dart' show kPlayerCoverHeroTag;
import 'player_provider.dart';
/// Compact player bar mounted at the bottom of the app shell. Mini
/// view only — the heavyweight shuffle/repeat/queue/volume controls
/// live in NowPlayingScreen, accessible by tap or swipe-up.
///
/// ┌─────────────────────────────────────┬──────────┐
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
/// │ Artist │ │
/// ├─────────────────────────────────────────────────┤
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
/// └─────────────────────────────────────────────────┘
///
/// Tap anywhere on the bar (including art/title) ascends to the full
/// player. A vertical drag upward also ascends, so the gesture mirrors
/// the drag-down dismissal on the full screen.
class PlayerBar extends ConsumerWidget {
const PlayerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final mediaItem = ref.watch(mediaItemProvider).value;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (mediaItem == null) return const SizedBox.shrink();
if (media == null) return const SizedBox.shrink();
// positionProvider is just_audio's positionStream (~200ms cadence)
// so the mini bar's seek crawls forward smoothly. PlaybackState
// only updates on event transitions and would leave it frozen.
final pos = ref.watch(positionProvider).value ?? Duration.zero;
final dur = media.duration ?? Duration.zero;
return Material(
color: fs.iron,
child: InkWell(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context.push('/now-playing'),
// Swipe up anywhere on the bar to expand into the full player.
// We only act on a clear upward flick (>200 px/s) so a slow
// tap-with-tiny-jitter doesn't accidentally open the screen.
onVerticalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
if (v < -200) context.push('/now-playing');
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(children: [
Container(width: 48, height: 48, color: fs.slate),
const SizedBox(width: 12),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14)),
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12)),
],
)),
IconButton(
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (playback?.playing == true) { h.pause(); } else { h.play(); }
},
),
IconButton(
icon: Icon(Icons.skip_next, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
]),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _TrackInfo(media: media)),
const SizedBox(width: 8),
_PlayControls(playback: playback, ref: ref),
],
),
),
const SizedBox(height: 4),
_SeekRow(position: pos, duration: dur),
],
),
),
),
);
}
}
class _TrackInfo extends StatelessWidget {
const _TrackInfo({required this.media});
final MediaItem media;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final artistName = (media.artist ?? '').trim();
// Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so
// tapping the mini bar to expand into NowPlayingScreen animates the
// artwork from this 48dp footprint to the full-screen size rather
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
// so the transition works regardless of what's playing.
Widget cover;
if (media.artUri != null) {
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
// hits the same disk cache the rest of the UI uses (ServerImage,
// discover thumbnails). FileImage stays for the file:// branch
// populated by AlbumCoverCache — bytes are already on disk and a
// second cache layer would only burn duplicate space.
cover = Image(
image: media.artUri!.isScheme('file')
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
: CachedNetworkImageProvider(media.artUri.toString()),
width: 48,
height: 48,
// Without a fit, Image paints the source at its intrinsic
// resolution inside the 48dp box — a thumbnail-sized cover
// would render as a tiny inset. Cover stretches/crops to fill
// the box uniformly.
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(width: 48, height: 48, color: fs.slate),
);
} else {
cover = Container(width: 48, height: 48, color: fs.slate);
}
return Row(
// Default centering vertically aligns the like/kebab buttons
// against the album art (48dp) — visually they span the title +
// artist block instead of sitting on the title baseline.
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Hero(
tag: kPlayerCoverHeroTag,
child: cover,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
media.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (artistName.isNotEmpty)
Text(
artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
// Like + kebab as siblings to the title/artist column rather
// than nested inside the title row. Width stays 32dp each so
// horizontal footprint matches what we had; height stretches
// to the row's full 48dp so the icons sit at vertical center
// against the title+artist block.
SizedBox(
width: 32,
height: 48,
child: LikeButton(
kind: LikeKind.track,
id: media.id,
size: 20,
),
),
SizedBox(
width: 32,
height: 48,
child: TrackActionsButton(
track: _trackRefFromMediaItem(media),
hideQueueActions: true,
),
),
],
);
}
}
class _PlayControls extends StatelessWidget {
const _PlayControls({required this.playback, required this.ref});
final PlaybackState? playback;
final WidgetRef ref;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final isPlaying = playback?.playing == true;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 22,
icon: Icon(Icons.skip_previous, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
),
SizedBox(
width: 44,
height: 44,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 32,
icon: Icon(
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
color: fs.accent,
),
onPressed: () {
final h = ref.read(audioHandlerProvider);
if (isPlaying) {
h.pause();
} else {
h.play();
}
},
),
),
SizedBox(
width: 36,
height: 36,
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 22,
icon: Icon(Icons.skip_next, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
),
],
);
}
}
class _SeekRow extends ConsumerWidget {
const _SeekRow({required this.position, required this.duration});
final Duration position;
final Duration duration;
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final maxMs =
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
return Row(
children: [
SizedBox(
width: 36,
child: Text(
_fmt(position),
textAlign: TextAlign.right,
style: TextStyle(color: fs.ash, fontSize: 10),
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: Slider(
activeColor: fs.accent,
inactiveColor: fs.slate,
min: 0,
max: maxMs,
value: position.inMilliseconds
.toDouble()
.clamp(0.0, duration.inMilliseconds.toDouble()),
onChanged: (v) => ref
.read(audioHandlerProvider)
.seek(Duration(milliseconds: v.toInt())),
),
),
),
SizedBox(
width: 36,
child: Text(
_fmt(duration),
style: TextStyle(color: fs.ash, fontSize: 10),
),
),
],
);
}
}
/// Reconstructs a minimal TrackRef from a MediaItem so the
/// TrackActionsButton has the fields its sheet expects. Mirrors the
/// pattern used by NowPlayingScreen — extras['album_id'] is what
/// audio_handler stashed at queue-build time.
TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
id: media.id,
title: media.title,
albumId: (media.extras?['album_id'] as String?) ?? '',
albumTitle: media.album ?? '',
// 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;
@@ -30,6 +31,23 @@ final queueProvider = StreamProvider<List<MediaItem>>(
(ref) => ref.watch(audioHandlerProvider).queue,
);
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
/// modify via PlayerActions.setVolume(). Surfaced separately from
/// PlaybackState because audio_service's PlaybackState doesn't carry
/// volume — it's a player-side concern.
final volumeProvider = StreamProvider<double>(
(ref) => ref.watch(audioHandlerProvider).volumeStream,
);
/// Live playback position. just_audio emits at ~200ms cadence so seek
/// bars driven by this provider scrub smoothly. Don't use
/// PlaybackState.updatePosition for that — it only changes on state
/// transitions (play/pause/buffer/seek) and the bar would appear
/// frozen between events.
final positionProvider = StreamProvider<Duration>(
(ref) => ref.watch(audioHandlerProvider).positionStream,
);
class PlayerActions {
PlayerActions(this._ref);
final Ref _ref;
@@ -59,6 +77,41 @@ class PlayerActions {
final h = _ref.read(audioHandlerProvider);
await h.enqueue(track);
}
/// Toggles shuffle on/off. Pre-existing audio_service convention.
Future<void> toggleShuffle() async {
final h = _ref.read(audioHandlerProvider);
final state = await h.playbackState.first;
final next = state.shuffleMode == AudioServiceShuffleMode.none
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none;
await h.setShuffleMode(next);
}
/// Cycles repeat mode: none → all → one → none.
Future<void> cycleRepeat() async {
final h = _ref.read(audioHandlerProvider);
final state = await h.playbackState.first;
final next = switch (state.repeatMode) {
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
_ => AudioServiceRepeatMode.none,
};
await h.setRepeatMode(next);
}
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));
@@ -7,18 +7,49 @@ import '../cache/db.dart';
import '../models/playlist.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/track_actions/track_actions_button.dart';
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>()!;
// #402 wire-up: invalidate the detail provider on playlist.updated /
// .tracks_changed events whose payload matches the visible id. On
// .deleted matching this id, navigate back so the user isn't left
// staring at a gone-from-server playlist.
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e == null) return;
final eventPlaylistId = e.data['playlist_id'] as String?;
if (eventPlaylistId != id) return;
switch (e.kind) {
case 'playlist.updated':
case 'playlist.tracks_changed':
ref.invalidate(playlistDetailProvider(id));
case 'playlist.deleted':
if (context.mounted) context.pop();
}
});
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,27 +59,118 @@ 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,
title: headerName.isEmpty
? const SizedBox.shrink()
: Text(
headerName,
style: TextStyle(color: fs.parchment),
overflow: TextOverflow.ellipsis,
),
),
// AnimatedSwitcher between the skeleton body and the real body
// smooths the cold-visit moment when bulk detail lands. 220ms
// matches the per-tile reveal feel used on home / liked tabs.
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
child: detail.when(
loading: () => _SkeletonBody(
key: const ValueKey('skeleton'),
seed: seed,
),
orElse: () => const SizedBox.shrink(),
error: (e, _) => Center(
key: const ValueKey('error'),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (d) => _Body(key: const ValueKey('body'), detail: d),
),
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (d) => _Body(detail: d),
);
}
}
/// Cold-visit body: header from the seed (if any) + N skeleton rows.
/// N comes from the seed's trackCount so the row count matches the
/// real list when it lands — no layout jump on swap. Without a seed
/// (deep link straight to a playlist with no prior cache) the
/// skeleton renders a small default and grows when real data arrives.
class _SkeletonBody extends StatelessWidget {
const _SkeletonBody({super.key, this.seed});
final Playlist? seed;
static const _defaultSkeletonCount = 8;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final count = seed?.trackCount ?? _defaultSkeletonCount;
return ListView.builder(
itemCount: count + 1, // +1 for header
itemBuilder: (ctx, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (seed != null && seed!.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
seed!.description,
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Text(
seed == null
? 'Loading…'
: '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}',
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
);
}
return const _PlaylistTrackSkeleton();
},
);
}
}
/// Skeleton matched to _PlaylistTrackRow's layout (no cover image —
/// playlist rows are text-only). Two text-shaped placeholders for
/// title + secondary line, plus a duration block on the right.
class _PlaylistTrackSkeleton extends StatelessWidget {
const _PlaylistTrackSkeleton();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 200, height: 14, color: fs.slate),
const SizedBox(height: 6),
Container(width: 140, height: 12, color: fs.slate),
],
),
),
const SizedBox(width: 16),
Container(width: 32, height: 12, color: fs.slate),
],
),
);
}
}
class _Body extends ConsumerWidget {
const _Body({required this.detail});
const _Body({super.key, required this.detail});
final PlaylistDetail detail;
@override
@@ -179,51 +301,69 @@ class _Header extends ConsumerWidget {
}
}
class _PlaylistTrackRow extends StatelessWidget {
class _PlaylistTrackRow extends ConsumerWidget {
const _PlaylistTrackRow({required this.row, required this.onTap});
final PlaylistTrack row;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
final color = row.isAvailable ? fs.parchment : fs.ash;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 14,
decoration: row.isAvailable
? null
: TextDecoration.lineThrough,
// "Now playing" highlight — matches _QueueRow and TrackRow so the
// user sees which playlist row is current without reading the
// player bar. Unavailable rows never match.
final currentId = ref.watch(mediaItemProvider).value?.id;
final isCurrent = row.isAvailable &&
currentId != null &&
currentId == row.trackId;
final baseColor = row.isAvailable ? fs.parchment : fs.ash;
final titleColor = isCurrent ? fs.accent : baseColor;
return Container(
decoration: BoxDecoration(
color: isCurrent ? fs.iron : null,
border: isCurrent
? Border(left: BorderSide(color: fs.accent, width: 2))
: null,
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: titleColor,
fontSize: 14,
fontWeight:
isCurrent ? FontWeight.w500 : FontWeight.w400,
decoration: row.isAvailable
? null
: TextDecoration.lineThrough,
),
),
),
Text(
'${row.artistName} · ${row.albumTitle}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
Text(
'${row.artistName} · ${row.albumTitle}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (row.trackId != null)
TrackActionsButton(track: _toTrackRef(row)),
]),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
if (row.trackId != null)
TrackActionsButton(track: _toTrackRef(row)),
]),
),
),
);
}
@@ -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,4 +1,8 @@
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';
import '../api/endpoints/me.dart';
@@ -36,7 +40,21 @@ final playlistsListProvider =
fetchAndPopulate: () async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.list(kind: kind);
// 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);
@@ -49,7 +67,7 @@ final playlistsListProvider =
if (kind == 'user') return r.systemVariant == null;
if (kind == 'system') return r.systemVariant != null;
return true; // 'all'
});
}).toList();
final owned = filtered
.where((r) => r.userId == user.id)
.map((r) => r.toRef())
@@ -60,7 +78,14 @@ final playlistsListProvider =
.toList();
return PlaylistsList(owned: owned, public: pub);
},
isOnline: () async => (await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// Aggregate list — server may add system playlists (For-You /
// Discover) that the per-user delta sync doesn't always emit.
// Stale-while-revalidate ensures the home tile row reflects the
// current server state on every screen visit.
alwaysRefresh: true,
);
});
@@ -103,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);
@@ -157,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);
@@ -1,64 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../library/widgets/play_circle_button.dart';
import '../../models/playlist.dart';
import '../../models/track.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart';
import '../playlists_provider.dart';
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
/// name + optional system-variant badge below. Tap pushes
/// `/playlists/{id}`.
class PlaylistCard extends StatelessWidget {
/// `/playlists/{id}`. The bottom-right play button fetches the
/// playlist and starts playback from track 0; disabled when the
/// playlist has no tracks.
class PlaylistCard extends ConsumerWidget {
const PlaylistCard({super.key, required this.playlist});
final Playlist playlist;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final hasTracks = playlist.trackCount > 0;
return SizedBox(
width: 176,
child: GestureDetector(
onTap: () => context.push('/playlists/${playlist.id}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 144,
height: 144,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () =>
context.push('/playlists/${playlist.id}', extra: playlist),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Stack: collage + overlaid play button at bottom-right.
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
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,
fallback:
Icon(Icons.queue_music, color: fs.ash, size: 56),
),
),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
Positioned(
bottom: 6,
right: 6,
child: PlayCircleButton(
enabled: hasTracks,
onPressed: () => _playPlaylist(ref),
),
),
],
),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
),
),
),
),
]),
]),
),
),
),
);
}
/// Fetches the playlist via /api/playlists/{id}, materializes each
/// PlaylistTrack into a TrackRef (filtering out unavailable rows
/// whose `trackId` is null after a track-delete), and plays from
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
Future<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
final detail = await api.get(playlist.id);
final refs = <TrackRef>[];
for (final t in detail.tracks) {
if (t.trackId == null) continue;
refs.add(TrackRef(
id: t.trackId!,
title: t.title,
albumId: t.albumId ?? '',
albumTitle: t.albumTitle,
artistId: t.artistId ?? '',
artistName: t.artistName,
durationSec: t.durationSec,
streamUrl: t.streamUrl ?? '',
));
}
if (refs.isEmpty) return;
await ref.read(playerActionsProvider).playTracks(refs, initialIndex: 0);
}
}
@@ -1,7 +1,13 @@
import 'dart:async';
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/me.dart';
import '../api/endpoints/quarantine.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
@@ -10,58 +16,166 @@ final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
return QuarantineApi(await ref.watch(dioProvider.future));
});
/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_
/// mine via a drift watch() subscription set up in build(), so any drift
/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered
/// refresh) re-emits the list automatically.
///
/// API surface unchanged from the prior FutureProvider-backed controller
/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()`
/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory
/// AsyncNotifier state to drift so the Hidden tab paints from disk on
/// cold open and the quarantine list is queryable offline.
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
@override
Future<List<QuarantineMineRow>> build() async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).quarantineMine();
final db = ref.watch(appDbProvider);
// Subscribe to drift first so any mutation (incoming sync, flag/
// unflag, etc.) propagates without needing a manual invalidate.
// Disposed when the provider rebuilds or the listener detaches.
final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) {
state = AsyncData(rows.map(_rowToModel).toList());
});
ref.onDispose(sub.cancel);
// SWR refresh on every build so the freshest server-side state
// overtakes drift in the background. Don't await — UI gets the
// cached snapshot first, freshness lands later via the watch().
unawaited(_refreshFromServer());
final initial = await db.select(db.cachedQuarantineMine).get();
return initial.map(_rowToModel).toList();
}
/// Hits /api/quarantine/mine and replaces the local table with the
/// authoritative server set. Best-effort: offline / 5xx / unresolved
/// dependencies (e.g. uninitialised serverUrl in tests) all collapse
/// to a no-op — drift stays on the prior cached state.
Future<void> _refreshFromServer() async {
try {
final online = await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
if (!online) return;
final dio = await ref.read(dioProvider.future);
final fresh = await MeApi(dio).quarantineMine();
final db = ref.read(appDbProvider);
await db.transaction(() async {
// Full replace — quarantine list is small and we want server
// unflags from other devices to take effect. Doing this in a
// transaction means the watch() sees exactly one emission with
// the merged state, not a delete-then-insert flicker.
await db.delete(db.cachedQuarantineMine).go();
for (final r in fresh) {
await db
.into(db.cachedQuarantineMine)
.insertOnConflictUpdate(_modelToCompanion(r));
}
});
} catch (_) {
// Swallow — cached state stays visible.
}
}
/// True when this track is in the caller's quarantine list.
bool isHidden(String trackId) =>
(state.value ?? const []).any((r) => r.trackId == trackId);
/// Optimistic flag: prepends a synthetic row, calls server,
/// rolls back on error.
/// Optimistic flag: inserts the synthetic row into drift (watch fires
/// immediately so the UI updates), calls server, rolls back on error.
Future<void> flag(TrackRef track, String reason, String notes) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
if (current.any((r) => r.trackId == track.id)) return; // already hidden
final synthetic = QuarantineMineRow(
final db = ref.read(appDbProvider);
final existed = await (db.select(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(track.id)))
.getSingleOrNull();
if (existed != null) return; // already hidden
final companion = CachedQuarantineMineCompanion.insert(
trackId: track.id,
reason: reason,
notes: notes.isEmpty ? null : notes,
notes: notes.isEmpty
? const drift.Value.absent()
: drift.Value(notes),
createdAt: DateTime.now().toUtc().toIso8601String(),
trackTitle: track.title,
trackDurationMs: track.durationSec * 1000,
trackDurationMs: drift.Value(track.durationSec * 1000),
albumId: track.albumId,
albumTitle: track.albumTitle,
artistId: track.artistId,
artistName: track.artistName,
);
state = AsyncData([synthetic, ...current]);
await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion);
try {
final api = await ref.read(quarantineApiProvider.future);
await api.flag(track.id, reason, notes: notes);
} catch (e, st) {
state = AsyncData(current);
// Rollback the optimistic insert; watch() re-emits the prior set.
await (db.delete(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(track.id)))
.go();
Error.throwWithStackTrace(e, st);
}
}
/// Optimistic unflag: removes the row, calls server, rolls back on error.
/// Optimistic unflag: removes the drift row, calls server, restores
/// on error.
Future<void> unflag(String trackId) async {
final api = await ref.read(quarantineApiProvider.future);
final current = state.value ?? const <QuarantineMineRow>[];
final removed = current.where((r) => r.trackId == trackId).toList();
if (removed.isEmpty) return;
state = AsyncData(current.where((r) => r.trackId != trackId).toList());
final db = ref.read(appDbProvider);
final existing = await (db.select(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(trackId)))
.getSingleOrNull();
if (existing == null) return;
await (db.delete(db.cachedQuarantineMine)
..where((t) => t.trackId.equals(trackId)))
.go();
try {
final api = await ref.read(quarantineApiProvider.future);
await api.unflag(trackId);
} catch (e, st) {
state = AsyncData(current);
// Restore the row we just deleted — toCompanion(true) preserves
// every field including the original fetchedAt timestamp.
await db
.into(db.cachedQuarantineMine)
.insertOnConflictUpdate(existing.toCompanion(true));
Error.throwWithStackTrace(e, st);
}
}
static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) =>
QuarantineMineRow(
trackId: r.trackId,
reason: r.reason,
notes: r.notes,
createdAt: r.createdAt,
trackTitle: r.trackTitle,
trackDurationMs: r.trackDurationMs,
albumId: r.albumId,
albumTitle: r.albumTitle,
albumCoverArtPath: r.albumCoverArtPath,
artistId: r.artistId,
artistName: r.artistName,
);
static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) =>
CachedQuarantineMineCompanion.insert(
trackId: r.trackId,
reason: r.reason,
notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes),
createdAt: r.createdAt,
trackTitle: r.trackTitle,
trackDurationMs: drift.Value(r.trackDurationMs),
albumId: r.albumId,
albumTitle: r.albumTitle,
albumCoverArtPath: r.albumCoverArtPath == null
? const drift.Value.absent()
: drift.Value(r.albumCoverArtPath),
artistId: r.artistId,
artistName: r.artistName,
);
}
final myQuarantineProvider =
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/admin_request.dart';
import '../shared/live_events_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'requests_provider.dart';
@@ -13,6 +14,16 @@ class RequestsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// #402 wire-up: refresh the user's own requests list on
// request.status_changed. Server-side events are user-scoped
// (admin actions on someone else's request route to that other
// user's stream); the dispatcher's filter already drops mismatches.
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
final e = next.asData?.value;
if (e?.kind == 'request.status_changed') {
ref.invalidate(myRequestsProvider);
}
});
final requests = ref.watch(myRequestsProvider);
return Scaffold(
@@ -0,0 +1,104 @@
// Maps incoming LiveEvent kinds to provider invalidations.
//
// Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once
// activated, the dispatcher listens to liveEventsProvider for the
// lifetime of the ProviderScope and invalidates the small set of
// public-scoped providers we know about.
//
// Screen-scoped providers (file-private providers in library_screen.dart,
// admin handlers, etc.) opt in to live-refresh by themselves listening
// to liveEventsProvider — the dispatcher only handles cross-screen,
// publicly-importable providers. This keeps the dispatcher small and
// avoids a back-edge dependency from /shared onto every feature folder.
//
// Includes an AppLifecycleState resume handler that defensively
// invalidates the same set on app foreground. SSE will catch up on its
// own, but on cold-start or after a long background period the
// re-invalidate is fast and avoids stale data flashing before the
// stream reconnects.
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart' show homeProvider;
import '../quarantine/quarantine_provider.dart';
import 'live_events_provider.dart';
/// Activates the SSE → invalidation dispatcher for the lifetime of the
/// containing ProviderScope. Read this once at startup (from app.dart);
/// the provider's body runs once, sets up listeners, and returns a
/// sentinel value.
final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) {
final d = _LiveEventsDispatcher(ref);
ref.onDispose(d.dispose);
return d;
});
class _LiveEventsDispatcher with WidgetsBindingObserver {
_LiveEventsDispatcher(this._ref) {
// Subscribe to the event stream. Each emitted event invokes
// _handle. Errors / disconnects auto-rebuild the underlying
// provider; we don't need to react to them here.
_sub = _ref.listen<AsyncValue<LiveEvent>>(
liveEventsProvider,
(_, next) => next.whenData(_handle),
fireImmediately: false,
);
WidgetsBinding.instance.addObserver(this);
}
final Ref _ref;
late final ProviderSubscription<AsyncValue<LiveEvent>> _sub;
void _handle(LiveEvent e) {
// Map event kinds to provider invalidations. Keep this list short:
// only providers reachable from /shared. Screen-private providers
// listen to liveEventsProvider themselves.
switch (e.kind) {
case 'quarantine.flagged':
case 'quarantine.unflagged':
case 'quarantine.resolved':
case 'quarantine.file_deleted':
case 'quarantine.deleted_via_lidarr':
_ref.invalidate(myQuarantineProvider);
// Hidden / liked / album rows referencing a deleted track may
// now be stale — homeProvider re-fetch refreshes the cards.
_ref.invalidate(homeProvider);
case 'playlist.created':
case 'playlist.updated':
case 'playlist.deleted':
case 'playlist.tracks_changed':
// Home renders a Playlists row; refresh it so a delete or add
// is reflected immediately. Detail screens that need the
// mutated row will get a separate invalidate from their own
// listener (filed as a follow-up).
_ref.invalidate(homeProvider);
case 'scan.run_started':
case 'scan.run_finished':
// Admin scan card provider lives in the admin feature folder
// and is file-private today; will be invalidated by the admin
// dashboard's own listener once that exists.
break;
default:
// track.liked / track.unliked / request.status_changed reach
// screen-private providers; their screens listen directly.
break;
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Defensive cold-start invalidation. SSE will catch up on its
// own, but the re-invalidate flushes any stale data that
// landed while the app was backgrounded.
_ref.invalidate(myQuarantineProvider);
_ref.invalidate(homeProvider);
}
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_sub.close();
}
}
@@ -0,0 +1,126 @@
// Subscribes to the server's Server-Sent Events stream
// (GET /api/events/stream, see Fable #392) and exposes a parsed event
// stream as a Riverpod StreamProvider. Consumers wire invalidation
// behavior in live_events_dispatcher.dart.
//
// On disconnect (network blip, server restart, token rotation), the
// provider's StreamController completes; the dispatcher's auto-rebuild
// when the auth state changes re-subscribes. Explicit exponential
// backoff lives at the dispatcher layer so we don't re-create the dio
// connection too aggressively.
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../library/library_providers.dart' show dioProvider;
/// Parsed event from the server's SSE stream. `kind` follows
/// "domain.action" naming; `data` carries the payload map.
class LiveEvent {
const LiveEvent({required this.kind, required this.userId, required this.data});
final String kind;
final String userId;
final Map<String, dynamic> data;
@override
String toString() => 'LiveEvent($kind, user=$userId, data=$data)';
}
/// Streams parsed LiveEvent values from /api/events/stream. Errors and
/// disconnects surface as stream errors; the dispatcher decides whether
/// to retry.
final liveEventsProvider = StreamProvider<LiveEvent>((ref) async* {
// Gate the subscription on having both a server URL and a session
// token. If either is missing, emit nothing and let the provider
// auto-rebuild when auth state lands.
final token = await ref.watch(sessionTokenProvider.future);
if (token == null || token.isEmpty) {
return;
}
final dio = await ref.watch(dioProvider.future);
final controller = StreamController<LiveEvent>();
ref.onDispose(controller.close);
// ignore: unawaited_futures
_runSubscription(dio, controller);
yield* controller.stream;
});
/// Runs the dio streaming request and pushes parsed events into
/// [controller]. Closes the controller when the stream ends or errors.
Future<void> _runSubscription(Dio dio, StreamController<LiveEvent> controller) async {
try {
final resp = await dio.get<ResponseBody>(
'/api/events/stream',
options: Options(
responseType: ResponseType.stream,
// No timeout — the server emits 15s heartbeats; idle timeouts
// on the client side would tear down a healthy connection.
receiveTimeout: Duration.zero,
headers: const {'Accept': 'text/event-stream'},
),
);
// SSE frames are delimited by blank lines. Accumulate raw bytes
// into a string buffer; flush parsed events on each "\n\n".
var buffer = '';
await for (final chunk in resp.data!.stream) {
buffer += utf8.decode(chunk, allowMalformed: true);
while (true) {
final i = buffer.indexOf('\n\n');
if (i < 0) break;
final frame = buffer.substring(0, i);
buffer = buffer.substring(i + 2);
final event = _parseFrame(frame);
if (event != null && !controller.isClosed) {
controller.add(event);
}
}
}
if (!controller.isClosed) {
await controller.close();
}
} catch (e, st) {
if (!controller.isClosed) {
controller.addError(e, st);
await controller.close();
}
}
}
/// Parses one SSE frame. Heartbeat comments (starting with ":") and
/// frames without a `data:` line return null. Frames with a `data:`
/// payload that doesn't parse as JSON are also dropped (logged at
/// debug level by the caller if needed).
LiveEvent? _parseFrame(String frame) {
String? kind;
String? dataLine;
for (final line in frame.split('\n')) {
if (line.isEmpty || line.startsWith(':')) {
continue;
}
if (line.startsWith('event:')) {
kind = line.substring(6).trim();
} else if (line.startsWith('data:')) {
dataLine = line.substring(5).trim();
}
}
if (dataLine == null) return null;
try {
final decoded = jsonDecode(dataLine) as Map<String, dynamic>;
return LiveEvent(
kind: kind ?? (decoded['kind'] as String? ?? ''),
userId: decoded['user_id'] as String? ?? '',
data: (decoded['data'] as Map<String, dynamic>?) ?? const {},
);
} catch (_) {
return null;
}
}
+77 -8
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';
@@ -18,6 +21,7 @@ import '../playlists/playlists_list_screen.dart';
import '../requests/requests_screen.dart';
import '../search/search_screen.dart';
import '../settings/settings_screen.dart';
import '../update/client_update_provider.dart';
import '../update/update_banner.dart';
import '../admin/admin_landing_screen.dart';
import '../admin/admin_requests_screen.dart';
@@ -54,20 +58,62 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/', redirect: (_, __) => '/home'),
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
// /now-playing lives outside the ShellRoute on purpose. The full
// player IS the player UI when active, and we don't want the mini
// bar from the shell underneath fighting for the bottom strip.
// Pushing this route unmounts the shell entirely; the slide-up
// transition still feels right because the shell stays painted
// for the duration of the animation.
GoRoute(
path: '/now-playing',
pageBuilder: (_, __) => CustomTransitionPage(
child: const NowPlayingScreen(),
transitionDuration: const Duration(milliseconds: 280),
reverseTransitionDuration: const Duration(milliseconds: 240),
transitionsBuilder: (_, anim, __, child) {
final eased = CurvedAnimation(
parent: anim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(eased),
child: child,
);
},
),
),
// /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: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
@@ -75,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()),
@@ -88,15 +137,35 @@ GoRouter buildRouter(Ref ref) {
);
}
class _ShellWithPlayerBar extends StatelessWidget {
class _ShellWithPlayerBar extends ConsumerWidget {
const _ShellWithPlayerBar({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
// The banner sits above the routed child and uses SafeArea to clear
// the system status bar. MediaQuery.padding is screen-relative
// though, so the child's Scaffold/AppBar would still apply its own
// top status-bar inset on top of the banner — doubling the gap.
// When the banner is showing, strip that inset from the child so
// its AppBar lands directly under the banner.
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
return Column(
children: [
// VersionTooOldBanner above UpdateBanner: a server-rejects-you
// soft warning carries more user-relevant urgency than the APK
// download prompt below it. The two banners can coexist
// (server says you're too old AND a local APK is queued).
const VersionTooOldBanner(),
const UpdateBanner(),
Expanded(child: child),
Expanded(
child: hasBanner
? MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
)
: child,
),
const PlayerBar(),
],
);
@@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/auth_provider.dart';
/// Image.network wrapper that resolves server-relative URLs (e.g.
/// CachedNetworkImage wrapper that resolves server-relative URLs (e.g.
/// `/api/albums/<id>/cover`) against the configured server base URL
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
/// unchanged. Empty/null base or empty URL render the [fallback]
@@ -12,6 +13,11 @@ import '../../auth/auth_provider.dart';
///
/// Mirrors the absolute-or-relative logic the audio handler uses for
/// stream URLs, so cover art and audio resolve URLs the same way.
///
/// Uses CachedNetworkImage so cover bytes land on disk (path_provider
/// temp dir, keyed by URL) and survive scroll-off + app restart. The
/// previous Image.network behavior cached only in-memory, so the cold-
/// start home grid re-downloaded every cover.
class ServerImage extends ConsumerWidget {
const ServerImage({
super.key,
@@ -31,15 +37,43 @@ class ServerImage extends ConsumerWidget {
final base = ref.watch(serverUrlProvider).value;
final resolved = _resolve(base, url);
if (resolved == null) return empty;
// Cover endpoints are gated by RequireUser server-side. Image.network
// doesn't carry cookies/Bearer tokens automatically the way the
// browser's <img> tag does, so we forward the session token as a
// header. Without this, the server returns 401 and the image fails.
final token = ref.watch(sessionTokenProvider).value;
final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'}
: null;
return Image.network(resolved, fit: fit, headers: headers);
// Cover endpoints are gated by RequireUser server-side. The image
// loader doesn't carry cookies/Bearer tokens automatically the way
// the browser's <img> tag does, so we forward the session token as
// a header. Without this, the server returns 401 and the image
// fails.
//
// sessionTokenProvider is a FutureProvider — on first read after a
// hot restart its .value is null until the secure-storage read
// resolves. If we mounted the image with httpHeaders:null at that
// moment, the disk cache would lock in the 401 response and never
// retry. Hold the fallback until the token is available, then
// mount the image with the auth header attached.
final tokenAsync = ref.watch(sessionTokenProvider);
return tokenAsync.when(
loading: () => empty,
error: (_, __) => empty,
data: (token) {
final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'}
: <String, String>{};
return CachedNetworkImage(
imageUrl: resolved,
httpHeaders: headers,
fit: fit,
// 120ms feels like cover bytes settling in on a cache miss
// (smoother than the abrupt zero-fade); on cache hits the
// image is decoded synchronously so the fade is imperceptible.
// The default 500ms is too long — looks like a regression on
// a populated grid.
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
// Keep failures local — a single 401/timeout shouldn't dump
// a stack trace or replace the parent Container's background.
errorWidget: (_, __, ___) => empty,
);
},
);
}
static String? _resolve(String? baseUrl, String url) {
@@ -0,0 +1,199 @@
// Skeleton placeholder widgets for the per-item rendering architecture
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
//
// Each skeleton matches the exact dimensions of its corresponding real
// card so the layout doesn't shift when content lands. A subtle
// shimmer sweep makes the page feel alive while individual tiles
// hydrate against drift / REST in the background.
//
// Self-contained shimmer (no `shimmer` package dep) so we can keep
// pubspec lean and tune the sweep colors against FabledSword tokens.
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Period of one full shimmer sweep across the skeleton.
const Duration _shimmerPeriod = Duration(milliseconds: 1200);
/// Wraps a child with a slow-moving highlight band on top of the
/// skeleton's base color. Cheap — uses a single AnimationController
/// per surface and a LinearGradient shader.
class _Shimmer extends StatefulWidget {
const _Shimmer({required this.child});
final Widget child;
@override
State<_Shimmer> createState() => _ShimmerState();
}
class _ShimmerState extends State<_Shimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl = AnimationController(
vsync: this,
duration: _shimmerPeriod,
)..repeat();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (rect) {
// Sweep starts off-screen left, ends off-screen right.
final dx = (_ctrl.value * 2 - 1) * rect.width;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
fs.slate,
fs.iron,
fs.slate,
],
stops: const [0.0, 0.5, 1.0],
transform: _SlideGradient(dx),
).createShader(rect);
},
child: child,
);
},
child: widget.child,
);
}
}
/// Helper for translating a gradient horizontally inside its rect.
class _SlideGradient extends GradientTransform {
const _SlideGradient(this.dx);
final double dx;
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(dx, 0, 0);
}
}
/// Skeleton matched to AlbumCard's 140px outer width / 124px cover /
/// title + artist text rows. Used in horizontal carousels where the
/// real card lives.
class SkeletonAlbumTile extends StatelessWidget {
const SkeletonAlbumTile({super.key, this.width = 140});
final double width;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return _Shimmer(
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: coverSize,
height: coverSize,
color: fs.slate,
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.8, height: 14, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.6, height: 12, color: fs.slate),
],
),
),
),
);
}
}
/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape.
class SkeletonArtistTile extends StatelessWidget {
const SkeletonArtistTile({super.key, this.width = 140});
final double width;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return _Shimmer(
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
shape: BoxShape.circle,
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 14, color: fs.slate),
],
),
),
),
);
}
}
/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover +
/// title + artist line, similar to the real row.
class SkeletonTrackRow extends StatelessWidget {
const SkeletonTrackRow({super.key});
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return _Shimmer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 180, height: 14, color: fs.slate),
const SizedBox(height: 6),
Container(width: 120, height: 12, color: fs.slate),
],
),
),
],
),
),
);
}
}
@@ -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(),
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -6,61 +9,255 @@ import 'package:pub_semver/pub_semver.dart';
import '../../api/client.dart';
import '../../api/endpoints/health.dart';
import '../../auth/auth_provider.dart';
import '../../theme/theme_extension.dart';
final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async {
final url = await ref.watch(serverUrlProvider.future);
if (url == null) return _VersionResult.skipped;
final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null);
final body = await HealthApi(dio).check();
final min = body['min_client_version'];
if (min == null || min.isEmpty) return _VersionResult.skipped;
final info = await PackageInfo.fromPlatform();
final mine = Version.parse(info.version);
final required = Version.parse(min);
return mine < required ? _VersionResult.tooOld : _VersionResult.ok;
});
/// Result of the most recent /healthz version compatibility check.
///
/// `skipped` means the server didn't emit a `min_client_version` field
/// (older servers, partial deploys). Treat as compatible.
enum VersionResult { ok, tooOld, skipped }
enum _VersionResult { ok, tooOld, skipped }
VersionResult _resultFromString(String? s) {
switch (s) {
case 'ok':
return VersionResult.ok;
case 'tooOld':
return VersionResult.tooOld;
default:
return VersionResult.skipped;
}
}
class VersionGate extends ConsumerWidget {
String _stringFromResult(VersionResult r) {
switch (r) {
case VersionResult.ok:
return 'ok';
case VersionResult.tooOld:
return 'tooOld';
case VersionResult.skipped:
return 'skipped';
}
}
/// Drives the version-compatibility check against /healthz. Non-blocking:
/// the controller hydrates from a 1h-throttled cache on boot, exposes
/// the current `VersionResult`, and refreshes itself in the background.
/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide
/// whether to surface the "too old" banner — never blocks rendering.
///
/// Cache keys live in flutter_secure_storage so the cadence survives
/// app restarts. 1h throttle bounds /healthz traffic; explicit
/// `recheck()` (from the banner's "Check now" button or app resume)
/// bypasses the throttle.
class VersionCheckController extends AsyncNotifier<VersionResult> {
static const _kResult = 'version_check_result';
static const _kAtMs = 'version_check_at_ms';
// 1 minute. The /healthz response is sub-1KB and the call is
// non-blocking, so a tight cadence buys faster recovery from
// server-side min_client_version bumps without measurable cost. The
// gate also acts as a safety net against duplicate calls when the
// periodic timer and a resume event fire close together.
static const _staleMs = 60 * 1000;
@override
Future<VersionResult> build() async {
final storage = ref.read(secureStorageProvider);
final resultStr = await storage.read(key: _kResult);
final atStr = await storage.read(key: _kAtMs);
final cached = _resultFromString(resultStr);
final at = int.tryParse(atStr ?? '') ?? 0;
final nowMs = DateTime.now().millisecondsSinceEpoch;
// Fire background recheck on cold-start when cache is missing or stale.
// Doesn't block: build returns the cached result immediately; the
// recheck updates state asynchronously when it lands.
if (nowMs - at > _staleMs) {
Future.microtask(_runCheck);
}
return cached;
}
/// Force a fresh check, bypassing the 1h staleness gate. Used by the
/// "Check now" banner button and (indirectly) by the AppLifecycleState
/// resume observer in VersionGate.
Future<void> recheck() async => _runCheck();
/// Recheck only if the cache is older than the 1h throttle. No-op
/// when fresh. Used on app resume to avoid hammering /healthz when
/// the user is briefly switching between apps.
Future<void> recheckIfStale() async {
final storage = ref.read(secureStorageProvider);
final atStr = await storage.read(key: _kAtMs);
final at = int.tryParse(atStr ?? '') ?? 0;
if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) {
await _runCheck();
}
}
Future<void> _runCheck() async {
try {
final url = await ref.read(serverUrlProvider.future);
if (url == null || url.isEmpty) return;
// Bounded timeouts for a health probe — the default
// ApiClient.buildDio dio is tuned for actual data fetches
// (8s connect + 30s receive). /healthz should resolve in
// tens of milliseconds; failing fast unblocks slow networks.
final dio = ApiClient.buildDio(
baseUrl: url,
tokenResolver: () async => null,
);
dio.options.connectTimeout = const Duration(seconds: 3);
dio.options.receiveTimeout = const Duration(seconds: 2);
final body = await HealthApi(dio).check();
final min = body['min_client_version'];
VersionResult result;
if (min == null || min.isEmpty) {
result = VersionResult.skipped;
} else {
final info = await PackageInfo.fromPlatform();
final mine = Version.parse(info.version);
final required = Version.parse(min);
result = mine < required ? VersionResult.tooOld : VersionResult.ok;
}
// Persist + flip state. Storage write before state assignment so
// a hot reload immediately after won't see a fresh state with a
// stale cache.
final storage = ref.read(secureStorageProvider);
await storage.write(key: _kResult, value: _stringFromResult(result));
await storage.write(
key: _kAtMs,
value: DateTime.now().millisecondsSinceEpoch.toString(),
);
state = AsyncData(result);
} on DioException catch (_) {
// Network error / timeout. Keep the cached state; don't bump the
// timestamp so the next staleness check still triggers a retry.
} catch (_) {
// Other errors (PackageInfo, parsing, etc.). Same handling —
// soft-fail so the UI never sees an error state from a defensive
// background check.
}
}
}
final versionCheckProvider =
AsyncNotifierProvider<VersionCheckController, VersionResult>(
VersionCheckController.new,
);
/// Wraps the shell. Non-blocking: always renders the child. Activates
/// the version check controller on mount and reruns it on app resume
/// (gated by the controller's 1h staleness throttle).
class VersionGate extends ConsumerStatefulWidget {
const VersionGate({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(_versionCheckProvider).when(
data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child,
error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
ConsumerState<VersionGate> createState() => _VersionGateState();
}
class _TooOldScreen extends StatelessWidget {
const _TooOldScreen();
class _VersionGateState extends ConsumerState<VersionGate>
with WidgetsBindingObserver {
// Polling interval during active foreground use. Paired with the
// controller's 1m staleness gate so concurrent fires (timer + resume)
// dedupe to a single network call. Tight cadence is affordable —
// /healthz is sub-1KB and the call is non-blocking.
static const _pollInterval = Duration(minutes: 1);
Timer? _pollTimer;
@override
Widget build(BuildContext context) {
return const Scaffold(
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Update required',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text(
'This client is too old for that Minstrel server. Install the latest build to continue.',
textAlign: TextAlign.center,
),
],
void initState() {
super.initState();
// Read once so the provider's build() runs; the controller
// hydrates from cache + fires a background check when stale.
ref.read(versionCheckProvider);
WidgetsBinding.instance.addObserver(this);
_startPolling();
}
@override
void dispose() {
_stopPolling();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(_pollInterval, (_) {
// ignore: unawaited_futures
ref.read(versionCheckProvider.notifier).recheckIfStale();
});
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
// Re-arm the timer + fire one immediate (staleness-gated) check
// so users who foreground after a long away period get a fresh
// result without waiting for the next tick.
// ignore: unawaited_futures
ref.read(versionCheckProvider.notifier).recheckIfStale();
_startPolling();
case AppLifecycleState.paused:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.detached:
// Stop firing while backgrounded — no need to burn battery on
// health probes the user can't see the result of.
_stopPolling();
}
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Banner that surfaces when the server reports our client is too old.
/// Non-blocking: the rest of the app keeps working (offline-mode-style
/// — locally cached metadata + audio still play). Tap "Check now" to
/// force a re-check after installing a new build.
class VersionTooOldBanner extends ConsumerWidget {
const VersionTooOldBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncResult = ref.watch(versionCheckProvider);
final result = asyncResult.asData?.value;
if (result != VersionResult.tooOld) return const SizedBox.shrink();
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SafeArea(
bottom: false,
child: Container(
color: fs.error.withValues(alpha: 0.15),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Icon(Icons.warning_amber_rounded, color: fs.error, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
"This app is older than the server requires. "
"You can keep using cached content; install an update when ready.",
style: TextStyle(color: fs.parchment, fontSize: 13),
),
),
),
TextButton(
onPressed: () =>
ref.read(versionCheckProvider.notifier).recheck(),
style: TextButton.styleFrom(
foregroundColor: fs.parchment,
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Text('Check now'),
),
]),
),
);
}
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pub_semver/pub_semver.dart';
import '../library/library_providers.dart' show dioProvider;
import 'installer.dart';
@@ -50,20 +49,52 @@ class ClientUpdateController extends AsyncNotifier<UpdateInfo?> {
}
/// True when `serverVersion` is strictly newer than `installedVersion`.
/// Both strings are normalized (leading 'v' stripped) and parsed as
/// semver. On parse failure, falls back to string inequality (treats
/// any difference as "newer" — operator can dismiss if wrong).
///
/// Comparison strategy: split both strings on `.`, parse each component
/// as an int (non-numeric = 0), pad the shorter list with zeros, then
/// compare component-wise. This handles our date-style versions
/// (2026.05.10.1) which exceed the 3-part semver shape that
/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as
/// equal to "2026.05.10.0" rather than "different = newer".
///
/// Falls back to pub_semver as a secondary attempt when the components
/// look semver-like (handles pre-release suffixes, build metadata, etc).
///
/// Exposed for testing; the polling logic in ClientUpdateController
/// is the only production caller.
bool isVersionNewer(String serverVersion, String installedVersion) {
final svr = serverVersion.replaceFirst(RegExp(r'^v'), '');
final ins = installedVersion.replaceFirst(RegExp(r'^v'), '');
try {
return Version.parse(svr) > Version.parse(ins);
} catch (_) {
return svr != ins;
List<int> parts(String v) => v
.replaceFirst(RegExp(r'^v'), '')
.split('.')
.map((p) => int.tryParse(p) ?? 0)
.toList();
final svr = parts(serverVersion);
final ins = parts(installedVersion);
// Safety net for non-numeric versions (e.g. branch-name builds like
// "main" vs "dev"): if neither side parsed any non-zero component,
// fall back to string inequality so an operator on a dev build
// still gets the banner instead of silently matching everything.
final svrAllZero = svr.every((c) => c == 0);
final insAllZero = ins.every((c) => c == 0);
if (svrAllZero && insAllZero) {
return serverVersion.replaceFirst(RegExp(r'^v'), '') !=
installedVersion.replaceFirst(RegExp(r'^v'), '');
}
final n = svr.length > ins.length ? svr.length : ins.length;
while (svr.length < n) {
svr.add(0);
}
while (ins.length < n) {
ins.add(0);
}
for (var i = 0; i < n; i++) {
if (svr[i] > ins[i]) return true;
if (svr[i] < ins[i]) return false;
}
return false;
}
final clientUpdateProvider =
+31 -15
View File
@@ -62,11 +62,12 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.system_update, color: fs.parchment, size: 18),
const SizedBox(width: 10),
Icon(Icons.system_update, color: fs.parchment, size: 16),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -76,11 +77,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
_stage == _Stage.error
? 'Update failed'
: 'Update Minstrel · ${info.version} available',
style: TextStyle(color: fs.parchment, fontSize: 13),
style: TextStyle(color: fs.parchment, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
if (_stage == _Stage.downloading) ...[
const SizedBox(height: 4),
const SizedBox(height: 2),
LinearProgressIndicator(
value: _progress > 0 ? _progress : null,
minHeight: 2,
@@ -89,11 +90,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
),
],
if (_stage == _Stage.error && _error != null) ...[
const SizedBox(height: 2),
const SizedBox(height: 1),
Text(
_error!,
style: TextStyle(color: fs.ash, fontSize: 11),
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
@@ -105,20 +106,35 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(
foregroundColor: fs.accent,
minimumSize: const Size(64, 36),
minimumSize: const Size(56, 28),
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('Install'),
child: const Text('Install', style: TextStyle(fontSize: 13)),
),
if (_stage == _Stage.error)
TextButton(
onPressed: () => _onInstall(info),
style: TextButton.styleFrom(foregroundColor: fs.accent),
child: const Text('Retry'),
style: TextButton.styleFrom(
foregroundColor: fs.accent,
minimumSize: const Size(56, 28),
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('Retry', style: TextStyle(fontSize: 13)),
),
SizedBox(
width: 32,
height: 32,
child: IconButton(
tooltip: 'Dismiss',
padding: EdgeInsets.zero,
iconSize: 16,
icon: Icon(Icons.close, color: fs.ash),
onPressed: () => _onDismiss(info),
),
IconButton(
tooltip: 'Dismiss',
icon: Icon(Icons.close, size: 18, color: fs.ash),
onPressed: () => _onDismiss(info),
),
],
),
+258 -2
View File
@@ -73,6 +73,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "2.15.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@@ -81,6 +153,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_config:
dependency: transitive
description:
@@ -89,6 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -113,6 +209,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
convert:
dependency: transitive
description:
@@ -145,6 +257,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
@@ -161,6 +289,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev"
source: hosted
version: "0.2.8"
fake_async:
dependency: transitive
description:
@@ -199,7 +351,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
@@ -283,6 +435,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_timezone:
dependency: "direct main"
description:
name: flutter_timezone
sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -320,6 +480,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.0"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
@@ -384,6 +552,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
just_audio:
dependency: "direct main"
description:
@@ -496,6 +672,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
node_preamble:
dependency: transitive
description:
@@ -512,6 +696,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
@@ -536,6 +728,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path:
dependency: transitive
description:
@@ -553,7 +753,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -640,6 +840,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
@@ -701,6 +917,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.3"
source_map_stack_trace:
dependency: transitive
description:
@@ -765,6 +989,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev"
source: hosted
version: "0.43.1"
stack_trace:
dependency: transitive
description:
@@ -789,6 +1037,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
+16 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 0.1.0+1
version: 2026.5.13+1
environment:
sdk: '>=3.5.0 <4.0.0'
@@ -28,6 +28,21 @@ dependencies:
drift_flutter: ^0.2.0
sqlite3_flutter_libs: ^0.5.24
connectivity_plus: ^6.0.5
flutter_timezone: ^4.1.1
palette_generator: ^0.3.3
# Disk-persistent image cache. Image.network only caches in memory, so
# cover art repainted after a scroll-off or app restart re-fetches from
# the server. cached_network_image stores the bytes under
# path_provider's temp dir keyed by URL, surviving both. Used directly
# by ServerImage (auth-aware path) and as CachedNetworkImageProvider
# for the mini bar (which composes its own Image widget).
cached_network_image: ^3.4.1
# flutter_cache_manager is the disk-cache layer cached_network_image
# sits on top of. SyncController uses DefaultCacheManager directly to
# pre-warm covers during metadata sync, so a cold-start home grid
# paints from disk on the very first scroll rather than firing a
# network round-trip per tile.
flutter_cache_manager: ^3.4.1
dev_dependencies:
flutter_test:
@@ -5,28 +5,25 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/api/endpoints/playlists.dart';
import 'package:minstrel/library/home_screen.dart';
import 'package:minstrel/library/library_providers.dart';
import 'package:minstrel/models/album.dart';
import 'package:minstrel/models/artist.dart';
import 'package:minstrel/models/home_data.dart';
import 'package:minstrel/models/home_index.dart';
import 'package:minstrel/models/playlist.dart';
import 'package:minstrel/models/system_playlists_status.dart';
import 'package:minstrel/playlists/playlists_provider.dart';
import 'package:minstrel/theme/theme_data.dart';
const _emptyHome = HomeData(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
// All tests pump an empty HomeIndex unless they care about populated
// section IDs — per-tile hydration is intentionally not exercised here
// (that requires drift, and the @Tags(['drift']) tier covers it).
// What this suite verifies is the screen's section-level shape:
// placeholders / empty-state copy / section presence given the index.
const _emptyIndex = HomeIndex.empty;
void main() {
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
(tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) async => _emptyHome),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
@@ -37,16 +34,17 @@ void main() {
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
));
await tester.pumpAndSettle();
// For-You placeholder + 3 Songs-like placeholders.
// For-You placeholder + Discover placeholder + 3 Songs-like placeholders.
expect(find.text('For You'), findsOneWidget);
expect(find.text('Discover'), findsOneWidget);
expect(find.text('Songs like…'), findsNWidgets(3));
});
testWidgets('renders empty-state copy for each section when home payload is empty',
testWidgets('renders empty-state copy for each section when the index is empty',
(tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) async => _emptyHome),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
@@ -87,7 +85,7 @@ void main() {
);
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) async => _emptyHome),
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(
const PlaylistsList(owned: [forYou], public: [])),
@@ -104,34 +102,8 @@ void main() {
expect(find.text('for you'), findsOneWidget);
});
testWidgets('renders home rollups when payload is non-empty', (tester) async {
const home = HomeData(
recentlyAddedAlbums: [
AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'),
],
rediscoverAlbums: [],
rediscoverArtists: [
ArtistRef(id: 'r', name: 'Aphex Twin'),
],
mostPlayedTracks: [],
lastPlayedArtists: [],
);
await tester.pumpWidget(ProviderScope(
overrides: [
homeProvider.overrideWith((ref) async => home),
playlistsListProvider('all').overrideWith(
(ref) => Stream.value(PlaylistsList.empty()),
),
systemPlaylistsStatusProvider.overrideWith(
(ref) async => SystemPlaylistsStatus.empty(),
),
],
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
));
await tester.pumpAndSettle();
expect(find.text('Recently added'), findsOneWidget);
expect(find.text('Geogaddi'), findsOneWidget);
expect(find.text('Rediscover'), findsOneWidget);
expect(find.text('Aphex Twin'), findsOneWidget);
});
// Section-with-populated-IDs coverage lives in the drift-tagged
// integration suite (Slice F follow-up) because per-tile providers
// require a real (in-memory) drift DB. The flutter-ci runner skips
// drift tests pending libsqlite3.
}
@@ -1,11 +1,23 @@
@Tags(['drift'])
library;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/api/endpoints/quarantine.dart';
import 'package:minstrel/models/quarantine_mine.dart';
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
import 'package:minstrel/cache/db.dart';
import 'package:minstrel/models/track.dart';
import 'package:minstrel/quarantine/quarantine_provider.dart';
// SKIP NOTE: drift's NativeDatabase needs the system libsqlite3.so.
// The flutter-ci runner image doesn't ship it. Same skip as
// sync_controller_test.dart — unblock by adding libsqlite3-dev to the
// CI image, or switch to sqlite3/wasm. On-device runs cover real
// behaviour today.
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
class _StubQuarantineApi implements QuarantineApi {
_StubQuarantineApi({this.shouldThrow = false});
bool shouldThrow;
@@ -25,13 +37,6 @@ class _StubQuarantineApi implements QuarantineApi {
}
}
class _StubController extends MyQuarantineController {
_StubController(this._initial);
final List<QuarantineMineRow> _initial;
@override
Future<List<QuarantineMineRow>> build() async => _initial;
}
const _track = TrackRef(
id: 't1',
title: 'Roygbiv',
@@ -44,44 +49,55 @@ const _track = TrackRef(
streamUrl: '',
);
const _existing = QuarantineMineRow(
trackId: 't1',
reason: 'bad_rip',
notes: null,
createdAt: '2026-05-01T00:00:00Z',
trackTitle: 'Roygbiv',
trackDurationMs: 137000,
albumId: 'a1',
albumTitle: 'Geogaddi',
artistId: 'ar1',
artistName: 'Boards of Canada',
);
ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
return ProviderContainer(overrides: [
appDbProvider.overrideWithValue(db),
quarantineApiProvider.overrideWith((ref) async => api),
]);
}
Future<void> _seed(AppDb db) async {
await db.into(db.cachedQuarantineMine).insert(
CachedQuarantineMineCompanion.insert(
trackId: 't1',
reason: 'bad_rip',
createdAt: '2026-05-01T00:00:00Z',
trackTitle: 'Roygbiv',
albumId: 'a1',
albumTitle: 'Geogaddi',
artistId: 'ar1',
artistName: 'Boards of Canada',
),
);
}
void main() {
test('flag prepends synthetic row and calls server', () async {
test('flag inserts a drift row and calls the server', skip: _skipDrift,
() async {
final db = AppDb(NativeDatabase.memory());
addTearDown(db.close);
final api = _StubQuarantineApi();
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [])),
]);
final container = _container(db: db, api: api);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
await container.read(myQuarantineProvider.notifier).flag(_track, 'bad_rip', '');
await container
.read(myQuarantineProvider.notifier)
.flag(_track, 'bad_rip', '');
expect(api.flagCalls, 1);
final list = container.read(myQuarantineProvider).value!;
expect(list, hasLength(1));
expect(list.first.trackId, 't1');
expect(list.first.reason, 'bad_rip');
final rows = await db.select(db.cachedQuarantineMine).get();
expect(rows, hasLength(1));
expect(rows.first.trackId, 't1');
expect(rows.first.reason, 'bad_rip');
});
test('flag rolls back on server failure', () async {
test('flag rolls back the drift insert on server failure', skip: _skipDrift,
() async {
final db = AppDb(NativeDatabase.memory());
addTearDown(db.close);
final api = _StubQuarantineApi(shouldThrow: true);
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [])),
]);
final container = _container(db: db, api: api);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
@@ -91,28 +107,33 @@ void main() {
.flag(_track, 'bad_rip', ''),
throwsException,
);
expect(container.read(myQuarantineProvider).value, isEmpty);
final rows = await db.select(db.cachedQuarantineMine).get();
expect(rows, isEmpty);
});
test('unflag removes the row and calls server', () async {
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
() async {
final db = AppDb(NativeDatabase.memory());
addTearDown(db.close);
await _seed(db);
final api = _StubQuarantineApi();
final container = ProviderContainer(overrides: [
quarantineApiProvider.overrideWith((ref) async => api),
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
]);
final container = _container(db: db, api: api);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
await container.read(myQuarantineProvider.notifier).unflag('t1');
expect(api.unflagCalls, 1);
expect(container.read(myQuarantineProvider).value, isEmpty);
final rows = await db.select(db.cachedQuarantineMine).get();
expect(rows, isEmpty);
});
test('isHidden reflects current state', () async {
final container = ProviderContainer(overrides: [
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
]);
test('isHidden reflects drift state', skip: _skipDrift, () async {
final db = AppDb(NativeDatabase.memory());
addTearDown(db.close);
await _seed(db);
final api = _StubQuarantineApi();
final container = _container(db: db, api: api);
addTearDown(container.dispose);
await container.read(myQuarantineProvider.future);
@@ -24,17 +24,19 @@ void main() {
expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse);
});
test('honors prerelease ordering', () {
// 0.1.0 > 0.1.0-rc.1 per semver
expect(isVersionNewer('0.1.0', '0.1.0-rc.1'), isTrue);
expect(isVersionNewer('0.1.0-rc.1', '0.1.0'), isFalse);
test('zero-pads shorter version when comparing', () {
// "1.2" treated as "1.2.0" — shorter side gets zero-padded so
// comparison is component-wise.
expect(isVersionNewer('1.2.1', '1.2'), isTrue);
expect(isVersionNewer('1.2', '1.2.1'), isFalse);
expect(isVersionNewer('1.2', '1.2.0'), isFalse);
});
});
group('isVersionNewer (date-style versions parse as semver)', () {
// pub_semver is permissive with leading zeros — '2026.05.10'
// parses as 2026.5.10. So date-style tags get strict ordering,
// not the unparseable-fallback path.
group('isVersionNewer (date-style versions)', () {
// Date tags are 3- or 4-part: 2026.05.10 or 2026.05.10.1. The
// numeric comparator parses each component as int (so "05" == 5)
// and zero-pads the shorter side.
test('newer date → newer', () {
expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue);
});
@@ -44,6 +46,18 @@ void main() {
test('equal date → not newer', () {
expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse);
});
test('leading zeros do not affect ordering', () {
// "2026.5.11" and "2026.05.11" parse to the same components.
// 12 > 5 numerically so month rollover stays correct.
expect(isVersionNewer('2026.5.11', '2026.05.11'), isFalse);
expect(isVersionNewer('2026.12.1', '2026.5.31'), isTrue);
});
test('4-part build suffix breaks the tie', () {
// 2026.05.10 vs 2026.05.10.1 — first three equal, server has a
// 4th component (1), client zero-pads → server is newer.
expect(isVersionNewer('2026.05.10.1', '2026.05.10'), isTrue);
expect(isVersionNewer('2026.05.10', '2026.05.10.0'), isFalse);
});
});
group('isVersionNewer (truly non-semver fallback)', () {
+5 -1
View File
@@ -5,10 +5,12 @@ go 1.23.0
require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/go-chi/chi/v5 v5.2.5
github.com/go-co-op/gocron/v2 v2.21.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.7.4
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.35.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -20,7 +22,9 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sync v0.11.0 // indirect
+12 -2
View File
@@ -21,6 +21,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -29,6 +31,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -44,6 +48,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -64,13 +70,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -81,6 +89,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+7
View File
@@ -111,6 +111,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
// Broadcast: affects every user who'd flagged this track.
h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
@@ -140,6 +142,9 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
}
return
}
// Broadcast: the track row is gone; every client's library/likes/queue
// invalidates so stale references stop showing.
h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
@@ -179,6 +184,8 @@ func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *htt
}
return
}
// Broadcast: same rationale as delete-file — track row gone everywhere.
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
+2
View File
@@ -125,6 +125,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
return
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
@@ -164,5 +165,6 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
return
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
+9 -1
View File
@@ -14,6 +14,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
@@ -27,7 +28,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -45,6 +46,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
eventbus: bus,
playlistScheduler: playlistScheduler,
}
r.Route("/api", func(api chi.Router) {
@@ -62,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/me/history", h.handleGetMyHistory)
authed.Put("/me/password", h.handleChangePassword)
authed.Put("/me/profile", h.handleUpdateMyProfile)
authed.Put("/me/timezone", h.handlePutTimezone)
authed.Get("/me/api-token", h.handleGetMyAPIToken)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
@@ -78,7 +82,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions)
authed.Get("/home", h.handleGetHome)
authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents)
authed.Get("/events/stream", h.handleEventsStream)
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
@@ -191,4 +197,6 @@ type handlers struct {
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
playlistScheduler *playlists.Scheduler
}
+11
View File
@@ -202,6 +202,17 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
map[string]any{"token": usedInviteToken})
}
// Register the new user with the playlist scheduler so their daily
// builds are scheduled without waiting for the hourly reconciliation
// pass. Failure here is logged but doesn't fail registration — the
// next hourly pass will pick them up.
if h.playlistScheduler != nil {
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
h.logger.Warn("api: scheduler refresh after registration",
"user_id", uuidToString(user.ID), "err", err)
}
}
writeJSON(w, http.StatusOK, LoginResponse{
Token: sessionToken,
User: UserView{
+109
View File
@@ -0,0 +1,109 @@
// Small helpers for posting live events to the SSE bus (#392). Producers
// call these from their handler success paths so the wire shape stays in
// one place. Each helper is a no-op when h.eventbus is nil (older callers
// or tests that don't construct a bus); production always has one.
package api
import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"github.com/jackc/pgx/v5/pgtype"
)
// publishLikeEvent broadcasts a track/album/artist like or unlike. The
// event is scoped to the user (so only their own clients invalidate); the
// payload includes the entity_type and entity_id so the dispatcher can
// invalidate the right query keys.
func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType string, liked bool) {
if h.eventbus == nil {
return
}
kind := "track.liked"
if !liked {
kind = "track.unliked"
}
if entityType == "album" {
if liked {
kind = "album.liked"
} else {
kind = "album.unliked"
}
}
if entityType == "artist" {
if liked {
kind = "artist.liked"
} else {
kind = "artist.unliked"
}
}
h.eventbus.Publish(eventbus.Event{
Kind: kind,
UserID: uuidToString(userID),
Data: map[string]any{
"entity_type": entityType,
"entity_id": uuidToString(entityID),
},
})
}
// publishQuarantineEvent broadcasts a quarantine action. User-side
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
// delete-via-lidarr are broadcast (empty UserID) because a single admin
// action can affect every user who'd flagged that track — every client
// invalidates and the non-affected ones no-op when the query result
// matches their cache.
func (h *handlers) publishQuarantineEvent(kind string, userID, trackID pgtype.UUID, broadcast bool) {
if h.eventbus == nil {
return
}
scope := ""
if !broadcast {
scope = uuidToString(userID)
}
h.eventbus.Publish(eventbus.Event{
Kind: kind,
UserID: scope,
Data: map[string]any{
"track_id": uuidToString(trackID),
},
})
}
// publishPlaylistEvent broadcasts a playlist mutation to the owner so
// their other clients invalidate the affected playlist (and playlist list)
// providers. Public-playlist subscribers are out of scope here — they'd
// need a separate "playlists.public_updated" broadcast, deferred until
// the multi-user case is exercised.
func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.UUID) {
if h.eventbus == nil {
return
}
h.eventbus.Publish(eventbus.Event{
Kind: kind,
UserID: uuidToString(ownerID),
Data: map[string]any{
"playlist_id": uuidToString(playlistID),
},
})
}
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
// route to the requester's user_id, not the admin's — the original user
// is who needs to see the update.
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
if h.eventbus == nil {
return
}
h.eventbus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: uuidToString(row.UserID),
Data: map[string]any{
"request_id": uuidToString(row.ID),
"status": string(row.Status),
"kind": string(row.Kind),
},
})
}
+135
View File
@@ -0,0 +1,135 @@
// Live event stream — Server-Sent Events at GET /api/events/stream.
//
// Clients (today: Flutter) subscribe to be notified of state changes
// elsewhere in the system (likes, request status flips, quarantine
// resolutions, scan progress, playlist mutations) so static screens
// can invalidate cached data without explicit user action.
//
// Wire-format: standard SSE. Each event has the shape
//
// event: <kind>
// data: <json payload>
//
// where <kind> follows "domain.action" naming and <json payload>
// matches `eventbus.Event`. A heartbeat comment line is emitted every
// 15 seconds to keep idle connections alive through proxies / NATs.
//
// Scoping: events with `user_id` matching the connected user OR with
// empty `user_id` (broadcast) are forwarded. Other users' events are
// dropped silently before reaching the wire.
package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
)
// userUUIDString renders a pgtype.UUID in canonical 8-4-4-4-12 form so
// the SSE handler can compare against eventbus.Event.UserID strings.
// Other packages keep their own copies of this same pattern; consolidating
// is out of scope for this slice.
func userUUIDString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
const (
// sseSubscriberBuffer bounds per-connection event queue depth. Events
// beyond this are dropped (logged at debug); the client resyncs via
// normal /api/* fetches when reconnecting or focusing.
sseSubscriberBuffer = 64
// sseHeartbeatInterval keeps proxy connections alive. SSE spec allows
// a comment line (starts with ":") as a no-op heartbeat.
sseHeartbeatInterval = 15 * time.Second
)
func (h *handlers) handleEventsStream(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
// Standard SSE headers. X-Accel-Buffering disables nginx's response
// buffering when Minstrel sits behind it; harmless on direct
// connections.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// Initial comment so the client sees the connection open immediately
// even if no real event lands for a while.
if _, err := fmt.Fprintf(w, ": connected\n\n"); err != nil {
return
}
flusher.Flush()
ch, unsub := h.eventbus.Subscribe(sseSubscriberBuffer)
defer unsub()
heartbeat := time.NewTicker(sseHeartbeatInterval)
defer heartbeat.Stop()
userIDStr := userUUIDString(user.ID)
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
case e, ok := <-ch:
if !ok {
// Bus closed our subscription (rare; only on full shutdown).
return
}
// Scope filter: forward only events scoped to this user or
// broadcast events (empty UserID).
if e.UserID != "" && e.UserID != userIDStr {
continue
}
if err := writeSSEEvent(w, e); err != nil {
return
}
flusher.Flush()
}
}
}
// writeSSEEvent serializes one bus event in SSE wire format. JSON
// marshal errors collapse the event to an "error" kind so the client
// at least sees something rather than silent drops.
func writeSSEEvent(w http.ResponseWriter, e eventbus.Event) error {
payload, err := json.Marshal(e)
if err != nil {
_, werr := fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"marshal failed\"}\n\n")
return werr
}
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Kind, payload)
return err
}
+48
View File
@@ -49,6 +49,54 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// handleGetHomeIndex implements GET /api/home/index. Returns the same
// five sections /api/home computes, but as flat slices of entity IDs
// rather than denormalized objects. Drives the Flutter per-item
// rendering path — the client hydrates each tile against the
// per-entity endpoints, so this response is tiny (a few KB at most)
// and the cold-visit round-trip is correspondingly short.
//
// Shares the recommendation.HomeData computation with /api/home; the
// DB cost is identical. A future optimisation could add ID-only
// query variants, but the JSON savings already shrink the wire
// payload by roughly an order of magnitude on populated libraries.
func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
data, err := recommendation.HomeData(r.Context(), h.pool, user.ID)
if err != nil {
h.logger.Error("api: home index data", "err", err)
writeErr(w, apierror.InternalMsg("failed to load home", err))
return
}
out := HomeIndexPayload{
RecentlyAddedAlbums: make([]string, 0, len(data.RecentlyAddedAlbums)),
RediscoverAlbums: make([]string, 0, len(data.RediscoverAlbums)),
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
}
for _, row := range data.RecentlyAddedAlbums {
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
}
for _, row := range data.RediscoverAlbums {
out.RediscoverAlbums = append(out.RediscoverAlbums, uuidToString(row.Album.ID))
}
for _, row := range data.RediscoverArtists {
out.RediscoverArtists = append(out.RediscoverArtists, uuidToString(row.Artist.ID))
}
for _, row := range data.MostPlayedTracks {
out.MostPlayedTracks = append(out.MostPlayedTracks, uuidToString(row.Track.ID))
}
for _, row := range data.LastPlayedArtists {
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
}
writeJSON(w, http.StatusOK, out)
}
// Compile-time guard that dbq has the row types we need; if the SQL is
// regenerated with a different name, this fails fast at build time.
var _ = dbq.ListMostPlayedTracksForUserRow{}.Track
+64
View File
@@ -77,3 +77,67 @@ func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) {
t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName)
}
}
func newHomeIndexRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/home/index", h.handleGetHomeIndex)
return r
}
func doGetHomeIndex(h *handlers, user dbq.User) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/home/index", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newHomeIndexRouter(h).ServeHTTP(w, req)
return w
}
func TestHomeIndex_EmptyForNewUser(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "pw", false)
w := doGetHomeIndex(h, user)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got HomeIndexPayload
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
// Same non-nil slice contract as /api/home so client parsers can
// trust `length == 0` instead of branching on null.
if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil ||
got.RediscoverArtists == nil || got.MostPlayedTracks == nil ||
got.LastPlayedArtists == nil {
t.Errorf("payload has nil slice: %+v", got)
}
if len(got.RecentlyAddedAlbums) != 0 {
t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
}
}
func TestHomeIndex_RecentlyAddedReturnsIDs(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "pw", false)
q := dbq.New(pool)
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "ArtistOne", SortName: "ArtistOne",
})
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID,
})
w := doGetHomeIndex(h, user)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var got HomeIndexPayload
_ = json.Unmarshal(w.Body.Bytes(), &got)
if len(got.RecentlyAddedAlbums) != 1 {
t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums))
}
want := uuidToString(album.ID)
if got.RecentlyAddedAlbums[0] != want {
t.Errorf("id = %q, want %q", got.RecentlyAddedAlbums[0], want)
}
}
+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)
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -443,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
paths := []string{
"/api/artists",
+6
View File
@@ -60,6 +60,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
}
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, id, "track", true)
w.WriteHeader(http.StatusNoContent)
}
@@ -83,6 +84,7 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
// Don't fail the response — soft-delete is best-effort.
}
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, id, "track", false)
w.WriteHeader(http.StatusNoContent)
}
@@ -111,6 +113,7 @@ func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
return
}
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, id, "album", true)
w.WriteHeader(http.StatusNoContent)
}
@@ -129,6 +132,7 @@ func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
return
}
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, id, "album", false)
w.WriteHeader(http.StatusNoContent)
}
@@ -157,6 +161,7 @@ func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
return
}
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, id, "artist", true)
w.WriteHeader(http.StatusNoContent)
}
@@ -175,6 +180,7 @@ func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
return
}
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, id, "artist", false)
w.WriteHeader(http.StatusNoContent)
}
+70
View File
@@ -0,0 +1,70 @@
// PUT /api/me/timezone — accept the client's IANA timezone and store
// it on the user row. The system-playlist scheduler (#392 Half B)
// reads this column to fire each user's daily 03:00 build in their
// local time.
//
// Scheduler.Refresh(ctx, userID) is invoked in a later commit (Task 3)
// so a timezone change takes effect synchronously rather than waiting
// for the hourly reconciliation pass.
package api
import (
"encoding/json"
"net/http"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// putTimezoneBody is the JSON body for PUT /api/me/timezone.
type putTimezoneBody struct {
Timezone string `json:"timezone"`
}
func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, apierror.Unauthorized("unauthorized", "authentication required"))
return
}
var body putTimezoneBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
if body.Timezone == "" {
writeErr(w, apierror.BadRequest("bad_body", "timezone is required"))
return
}
if _, err := time.LoadLocation(body.Timezone); err != nil {
writeErr(w, apierror.BadRequest("invalid_timezone",
"timezone must be a valid IANA name (e.g. America/New_York)"))
return
}
q := dbq.New(h.pool)
if err := q.UpdateUserTimezone(r.Context(), dbq.UpdateUserTimezoneParams{
ID: user.ID,
Timezone: body.Timezone,
}); err != nil {
h.logger.Error("api: update timezone", "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
// Reschedule the user's daily build at their new timezone. Failure
// here doesn't undo the DB write — the hourly reconciliation pass
// would pick up the new tz within an hour at worst.
if h.playlistScheduler != nil {
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
h.logger.Warn("api: scheduler refresh after timezone update",
"user_id", uuidToString(user.ID), "err", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func newMeTimezoneRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Put("/api/me/timezone", h.handlePutTimezone)
return r
}
func TestPutTimezone_ValidIANA(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tz1", "irrelevant1", false)
body := bytes.NewBufferString(`{"timezone":"America/New_York"}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTimezoneRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
// Verify the DB row was updated.
got, err := dbq.New(pool).GetUserByID(context.Background(), user.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
if got.Timezone != "America/New_York" {
t.Errorf("timezone = %q, want %q", got.Timezone, "America/New_York")
}
if !got.TimezoneUpdatedAt.Valid {
t.Errorf("expected timezone_updated_at to be set after PUT")
}
}
func TestPutTimezone_InvalidIANA(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tz2", "irrelevant1", false)
body := bytes.NewBufferString(`{"timezone":"Not/A/Real/Zone"}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTimezoneRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
var errResp struct {
Code string `json:"code"`
}
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
t.Fatalf("decode err response: %v", err)
}
if errResp.Code != "invalid_timezone" {
t.Errorf("error code = %q, want invalid_timezone", errResp.Code)
}
}
func TestPutTimezone_EmptyTimezone(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tz3", "irrelevant1", false)
body := bytes.NewBufferString(`{"timezone":""}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTimezoneRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
+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)
}
+11
View File
@@ -185,6 +185,7 @@ func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request)
h.writePlaylistErr(w, err, "create")
return
}
h.publishPlaylistEvent("playlist.created", caller.ID, row.ID)
writeJSON(w, http.StatusOK, playlistRowToView(row))
}
@@ -240,6 +241,7 @@ func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request)
h.writePlaylistErr(w, err, "update")
return
}
h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID)
writeJSON(w, http.StatusOK, playlistRowToView(row))
}
@@ -258,6 +260,7 @@ func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request)
h.writePlaylistErr(w, err, "delete")
return
}
h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID)
w.WriteHeader(http.StatusNoContent)
}
@@ -294,6 +297,7 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
h.writePlaylistErr(w, err, "append tracks")
return
}
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
h.writePlaylistErr(w, err, "get-after-append")
@@ -323,6 +327,7 @@ func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Requ
h.writePlaylistErr(w, err, "remove track")
return
}
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
h.writePlaylistErr(w, err, "get-after-remove")
@@ -355,6 +360,7 @@ func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request)
h.writePlaylistErr(w, err, "reorder")
return
}
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
h.writePlaylistErr(w, err, "get-after-reorder")
@@ -386,6 +392,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)
}
+5
View File
@@ -67,6 +67,9 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
}
return
}
// Broadcast: the flagging user's other clients invalidate their
// Hidden tab; admins' clients invalidate their quarantine queue.
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
}
@@ -89,6 +92,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.InternalMsg("unflag failed", err))
return
}
// Scoped to user: only their other clients need to invalidate.
h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false)
w.WriteHeader(http.StatusNoContent)
}
+2
View File
@@ -175,6 +175,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
}
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusCreated, requestViewFrom(row))
}
@@ -254,5 +255,6 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
return
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
+18
View File
@@ -118,6 +118,24 @@ type HomePayload struct {
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
}
// HomeIndexPayload is the response body of GET /api/home/index — the
// per-item rendering variant of /api/home. Same sections, same caps,
// but each section is a flat slice of entity IDs (UUID strings)
// instead of denormalized objects. Slices are non-nil at JSON encode
// time so empty sections render as `[]`.
//
// Client fetches this, then hydrates each tile against the per-entity
// endpoints (/api/albums/{id}, /api/artists/{id}, /api/tracks/{id})
// for genuine progressive rendering. The section name implies the
// entity type so no per-entry type tag is needed.
type HomeIndexPayload struct {
RecentlyAddedAlbums []string `json:"recently_added_albums"`
RediscoverAlbums []string `json:"rediscover_albums"`
RediscoverArtists []string `json:"rediscover_artists"`
MostPlayedTracks []string `json:"most_played_tracks"`
LastPlayedArtists []string `json:"last_played_artists"`
}
// HistoryEvent is one play in a user's listening history. Used by
// /api/me/history; one event per row from play_events.
type HistoryEvent struct {
+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
+7 -1
View File
@@ -12,7 +12,7 @@ import (
)
const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksForDiscover :many
SELECT DISTINCT t.id, t.album_id, t.artist_id
SELECT t.id, t.album_id, t.artist_id
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id != $1
@@ -30,6 +30,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY md5(t.id::text || $2::text)
LIMIT 60
`
@@ -50,6 +51,11 @@ type ListCrossUserLikedTracksForDiscoverRow struct {
// rows (the caller's slot redistribution rolls the deficit into the
// other two buckets).
// $1 = user_id, $2 = date string for md5 ordering.
//
// GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
// not appear in the select list — Postgres rejects DISTINCT + ORDER BY
// by-expression at plan time (SQLSTATE 42P10), which previously caused
// the entire Discover build to fail silently.
func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
+17
View File
@@ -7,8 +7,25 @@ package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteOldLibraryChanges = `-- name: DeleteOldLibraryChanges :execrows
DELETE FROM library_changes WHERE changed_at < $1
`
// Removes library_changes rows older than the given cutoff. Run from
// the sync compactor goroutine on a daily tick. Returns the number of
// rows deleted so the worker can log meaningful progress.
func (q *Queries) DeleteOldLibraryChanges(ctx context.Context, changedAt pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, deleteOldLibraryChanges, changedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getLibraryChangesSince = `-- name: GetLibraryChangesSince :many
SELECT id, entity_type, entity_id, op, changed_at
FROM library_changes
+2
View File
@@ -522,6 +522,8 @@ type User struct {
DisplayName *string
AutoApproveRequests bool
Email *string
Timezone string
TimezoneUpdatedAt pgtype.Timestamptz
}
type UserInvite struct {
+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 {
+31 -11
View File
@@ -308,7 +308,7 @@ SELECT p.artist_id,
FROM plays p
LEFT JOIN liked l ON l.artist_id = p.artist_id
ORDER BY score DESC, p.artist_id
LIMIT 3
LIMIT 5
`
type PickSeedArtistsRow struct {
@@ -316,7 +316,10 @@ type PickSeedArtistsRow struct {
Score int64
}
// Top-3 most-engaged distinct artists in the user's last 7 days.
// Top-5 most-engaged distinct artist candidates in the user's last 7
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
// daily-deterministically and takes the first 3 so the set of
// "Songs like X" mixes rotates day-to-day.
// Score = unskipped-play count + 5 if user has liked the artist.
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
@@ -408,7 +411,7 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic
return id, err
}
const pickTopPlayedTrackForUser = `-- name: PickTopPlayedTrackForUser :one
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
SELECT t.id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
@@ -417,16 +420,33 @@ SELECT t.id
AND pe.was_skipped = false
GROUP BY t.id
ORDER BY COUNT(*) DESC, t.id
LIMIT 1
LIMIT 5
`
// For-You seed selection. Returns the user's most-played non-skipped
// track in the last 7 days; tie-break by track_id for determinism.
func (q *Queries) PickTopPlayedTrackForUser(ctx context.Context, userID pgtype.UUID) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, pickTopPlayedTrackForUser, userID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
// For-You candidate seeds. Returns the user's top-5 most-played
// non-skipped tracks in the last 7 days; tie-break by track_id for
// determinism. The Go-side picker (pickForYouSeedForDay) chooses one
// of the returned rows as today's seed via userIDHash so the
// candidate pool rotates day-to-day while staying stable within a
// day.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const tryClaimSystemPlaylistRun = `-- name: TryClaimSystemPlaylistRun :one
+91 -11
View File
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserParams struct {
@@ -87,6 +87,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -94,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserAdminParams struct {
@@ -130,6 +132,8 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -141,7 +145,7 @@ VALUES (
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type CreateUserFirstAdminRaceParams struct {
@@ -185,6 +189,8 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -231,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
}
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE api_token = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
`
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
@@ -250,12 +256,14 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE lower(email) = lower($1)
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
`
// Used by forgot-password lookup. Lowercase comparison both sides
@@ -277,12 +285,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE id = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -301,12 +311,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE username = $1
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -325,10 +337,50 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
const listActiveUsersWithTimezones = `-- name: ListActiveUsersWithTimezones :many
SELECT u.id, u.timezone FROM users u
WHERE EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = u.id
AND pe.started_at > now() - INTERVAL '7 days'
)
`
type ListActiveUsersWithTimezonesRow struct {
ID pgtype.UUID
Timezone string
}
// Returns (id, timezone) for every user with a play in the last 7
// days. The system-playlist scheduler iterates this list at startup
// and during its hourly reconciliation to discover newly-active /
// no-longer-active users.
func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiveUsersWithTimezonesRow, error) {
rows, err := q.db.Query(ctx, listActiveUsersWithTimezones)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveUsersWithTimezonesRow
for rows.Next() {
var i ListActiveUsersWithTimezonesRow
if err := rows.Scan(&i.ID, &i.Timezone); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
FROM users
@@ -374,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
const regenerateApiToken = `-- name: RegenerateApiToken :one
UPDATE users SET api_token = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type RegenerateApiTokenParams struct {
@@ -400,6 +452,8 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -477,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserAdminParams struct {
@@ -503,6 +557,8 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -511,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserAutoApproveParams struct {
@@ -536,6 +592,8 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
@@ -545,7 +603,7 @@ UPDATE users
SET display_name = $2,
email = $3
WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
`
type UpdateUserProfileParams struct {
@@ -572,6 +630,28 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
)
return i, err
}
const updateUserTimezone = `-- name: UpdateUserTimezone :exec
UPDATE users
SET timezone = $2,
timezone_updated_at = now()
WHERE id = $1
`
type UpdateUserTimezoneParams struct {
ID pgtype.UUID
Timezone string
}
// Sets the user's IANA timezone and bumps timezone_updated_at. The
// handler validates the timezone string via time.LoadLocation before
// calling this; the DB does not re-validate.
func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error {
_, err := q.db.Exec(ctx, updateUserTimezone, arg.ID, arg.Timezone)
return err
}
@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN timezone,
DROP COLUMN timezone_updated_at;
@@ -0,0 +1,8 @@
-- 0026_users_timezone.up.sql — per-user timezone for the system-playlist
-- scheduler (#392 Half B). Default 'UTC' so existing rows + brand-new
-- users have a safe value before their first client check-in via
-- PUT /api/me/timezone. The scheduler reads this column to decide
-- which IANA zone to anchor each user's daily 03:00 build to.
ALTER TABLE users
ADD COLUMN timezone text NOT NULL DEFAULT 'UTC',
ADD COLUMN timezone_updated_at timestamptz;
+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;
+7 -1
View File
@@ -52,7 +52,12 @@ SELECT t.id, t.album_id, t.artist_id
-- rows (the caller's slot redistribution rolls the deficit into the
-- other two buckets).
-- $1 = user_id, $2 = date string for md5 ordering.
SELECT DISTINCT t.id, t.album_id, t.artist_id
--
-- GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
-- not appear in the select list — Postgres rejects DISTINCT + ORDER BY
-- by-expression at plan time (SQLSTATE 42P10), which previously caused
-- the entire Discover build to fail silently.
SELECT t.id, t.album_id, t.artist_id
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id != $1
@@ -70,6 +75,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY md5(t.id::text || $2::text)
LIMIT 60;
+6
View File
@@ -14,3 +14,9 @@ SELECT COALESCE(MAX(id), 0)::BIGINT FROM library_changes;
-- name: GetMinLibraryChangeCursor :one
SELECT COALESCE(MIN(id), 0)::BIGINT FROM library_changes;
-- name: DeleteOldLibraryChanges :execrows
-- Removes library_changes rows older than the given cutoff. Run from
-- the sync compactor goroutine on a daily tick. Returns the number of
-- rows deleted so the worker can log meaningful progress.
DELETE FROM library_changes WHERE changed_at < $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
+13 -6
View File
@@ -47,7 +47,10 @@ UPDATE system_playlist_runs
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many
-- Top-3 most-engaged distinct artists in the user's last 7 days.
-- Top-5 most-engaged distinct artist candidates in the user's last 7
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
-- daily-deterministically and takes the first 3 so the set of
-- "Songs like X" mixes rotates day-to-day.
-- Score = unskipped-play count + 5 if user has liked the artist.
WITH plays AS (
SELECT t.artist_id,
@@ -67,11 +70,15 @@ SELECT p.artist_id,
FROM plays p
LEFT JOIN liked l ON l.artist_id = p.artist_id
ORDER BY score DESC, p.artist_id
LIMIT 3;
LIMIT 5;
-- name: PickTopPlayedTrackForUser :one
-- For-You seed selection. Returns the user's most-played non-skipped
-- track in the last 7 days; tie-break by track_id for determinism.
-- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds. Returns the user's top-5 most-played
-- non-skipped tracks in the last 7 days; tie-break by track_id for
-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one
-- of the returned rows as today's seed via userIDHash so the
-- candidate pool rotates day-to-day while staying stable within a
-- day.
SELECT t.id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
@@ -80,7 +87,7 @@ SELECT t.id
AND pe.was_skipped = false
GROUP BY t.id
ORDER BY COUNT(*) DESC, t.id
LIMIT 1;
LIMIT 5;
-- name: PickTopPlayedTrackForArtistByUser :one
-- "Songs like X" seed selection. Returns the user's most-played non-skipped
+21
View File
@@ -142,3 +142,24 @@ RETURNING *;
-- to keep the unique index happy and to make the lookup
-- case-insensitive.
SELECT * FROM users WHERE lower(email) = lower($1);
-- name: UpdateUserTimezone :exec
-- Sets the user's IANA timezone and bumps timezone_updated_at. The
-- handler validates the timezone string via time.LoadLocation before
-- calling this; the DB does not re-validate.
UPDATE users
SET timezone = $2,
timezone_updated_at = now()
WHERE id = $1;
-- name: ListActiveUsersWithTimezones :many
-- Returns (id, timezone) for every user with a play in the last 7
-- days. The system-playlist scheduler iterates this list at startup
-- and during its hourly reconciliation to discover newly-active /
-- no-longer-active users.
SELECT u.id, u.timezone FROM users u
WHERE EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = u.id
AND pe.started_at > now() - INTERVAL '7 days'
);
+96
View File
@@ -0,0 +1,96 @@
// Package eventbus is an in-process publish/subscribe bus for real-time
// events the server emits to subscribed clients (today: Flutter via SSE).
//
// Writers (likes handler, requests reconciler, scanner, etc.) call Publish
// to announce a state change. Each connected SSE client holds a
// subscription via Subscribe and forwards events to its HTTP response.
//
// The bus is intentionally simple:
//
// - Per-subscriber bounded channel buffer. If a subscriber's buffer is
// full when an event arrives, the event is dropped FOR THAT SUBSCRIBER
// ONLY — writers never block, and other subscribers are unaffected.
// - No persistence. A client that connects after an event was emitted
// will not see that event. SSE clients are expected to fetch a fresh
// snapshot via the normal /api/* endpoints on (re)connect.
// - No cross-process broadcast. v1 runs as a single Go binary; a future
// multi-instance deployment will need a real broker (NATS / Redis
// pub-sub / etc.) but that's out of scope here.
package eventbus
import (
"sync"
)
// Event is a single broadcast message. Kind names follow a "domain.action"
// convention (e.g. "track.liked", "request.status_changed"). UserID, when
// non-empty, scopes the event to a specific user — subscribers receive
// only events with UserID == their auth user OR with empty UserID (which
// means "broadcast to everyone subscribed").
type Event struct {
Kind string `json:"kind"`
UserID string `json:"user_id,omitempty"`
Data map[string]any `json:"data"`
}
// Bus is a fan-out broadcaster. Safe for concurrent use; the zero value is
// not usable — call New().
type Bus struct {
mu sync.RWMutex
subscribers map[chan Event]struct{}
}
// New returns a Bus with no subscribers.
func New() *Bus {
return &Bus{subscribers: map[chan Event]struct{}{}}
}
// Publish fans an event out to every subscriber. Non-blocking per
// subscriber: if a subscriber's buffer is full, the event is dropped for
// that subscriber rather than stalling the writer.
func (b *Bus) Publish(e Event) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.subscribers {
select {
case ch <- e:
default:
// Subscriber buffer full — drop. The subscriber will resync
// via a normal API fetch when the client decides to.
}
}
}
// Subscribe registers a new subscriber and returns its receive channel
// plus an unsubscribe func. The buffer argument controls how many events
// can be queued before drops occur; 1664 is a reasonable range for an
// SSE client that processes events promptly.
//
// Callers MUST invoke the returned unsubscribe func when done (typically
// in a defer), or the subscription leaks.
func (b *Bus) Subscribe(buffer int) (<-chan Event, func()) {
if buffer <= 0 {
buffer = 16
}
ch := make(chan Event, buffer)
b.mu.Lock()
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
unsub := func() {
b.mu.Lock()
if _, ok := b.subscribers[ch]; ok {
delete(b.subscribers, ch)
close(ch)
}
b.mu.Unlock()
}
return ch, unsub
}
// SubscriberCount returns the current number of active subscribers. For
// tests + observability; not load-bearing in hot paths.
func (b *Bus) SubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subscribers)
}
+150
View File
@@ -0,0 +1,150 @@
package eventbus
import (
"sync"
"testing"
"time"
)
func TestPublish_DeliversToSubscriber(t *testing.T) {
t.Parallel()
b := New()
ch, unsub := b.Subscribe(4)
defer unsub()
b.Publish(Event{Kind: "test.event", UserID: "u1", Data: map[string]any{"x": 1}})
select {
case got := <-ch:
if got.Kind != "test.event" || got.UserID != "u1" {
t.Fatalf("unexpected event: %+v", got)
}
if got.Data["x"] != 1 {
t.Fatalf("payload mismatch: %+v", got.Data)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
}
func TestPublish_FanOutToMultipleSubscribers(t *testing.T) {
t.Parallel()
b := New()
ch1, unsub1 := b.Subscribe(4)
ch2, unsub2 := b.Subscribe(4)
defer unsub1()
defer unsub2()
b.Publish(Event{Kind: "broadcast"})
for i, ch := range []<-chan Event{ch1, ch2} {
select {
case got := <-ch:
if got.Kind != "broadcast" {
t.Fatalf("ch%d: kind=%q", i, got.Kind)
}
case <-time.After(time.Second):
t.Fatalf("ch%d: timed out", i)
}
}
}
func TestPublish_DropsWhenSubscriberBufferFull(t *testing.T) {
t.Parallel()
b := New()
_, unsub := b.Subscribe(2) // tiny buffer
defer unsub()
// Without draining the channel, push more events than the buffer holds.
// Drops are silent; the assertion is that Publish does not block.
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
b.Publish(Event{Kind: "flood"})
}
close(done)
}()
select {
case <-done:
// Good — Publish returned without blocking on the laggard.
case <-time.After(2 * time.Second):
t.Fatal("Publish blocked on a slow subscriber")
}
}
func TestUnsubscribe_RemovesFromBusAndClosesChannel(t *testing.T) {
t.Parallel()
b := New()
ch, unsub := b.Subscribe(4)
if got := b.SubscriberCount(); got != 1 {
t.Fatalf("after Subscribe: SubscriberCount=%d, want 1", got)
}
unsub()
if got := b.SubscriberCount(); got != 0 {
t.Fatalf("after unsub: SubscriberCount=%d, want 0", got)
}
// Channel is closed: receive returns zero value with ok=false.
if _, ok := <-ch; ok {
t.Fatal("expected channel to be closed after unsubscribe")
}
}
func TestUnsubscribe_Idempotent(t *testing.T) {
t.Parallel()
b := New()
_, unsub := b.Subscribe(4)
unsub()
unsub() // must not panic / double-close
if got := b.SubscriberCount(); got != 0 {
t.Fatalf("SubscriberCount=%d, want 0", got)
}
}
func TestPublish_ConcurrentWritersAndSubscribers(t *testing.T) {
t.Parallel()
b := New()
const (
subs = 8
perWriter = 50
writers = 4
bufferSize = 100
)
var subWG sync.WaitGroup
subWG.Add(subs)
stops := make([]func(), 0, subs)
for i := 0; i < subs; i++ {
ch, unsub := b.Subscribe(bufferSize)
stops = append(stops, unsub)
go func() {
defer subWG.Done()
for range ch {
// Drain until channel closes.
}
}()
}
var pubWG sync.WaitGroup
pubWG.Add(writers)
for w := 0; w < writers; w++ {
go func() {
defer pubWG.Done()
for i := 0; i < perWriter; i++ {
b.Publish(Event{Kind: "concurrent"})
}
}()
}
pubWG.Wait()
// Tear down subscribers — channels close, goroutines exit.
for _, stop := range stops {
stop()
}
subWG.Wait()
}
+81
View File
@@ -0,0 +1,81 @@
package library
import (
"sync"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
)
// Package-level bus pointer for scan lifecycle events (#392). Set once
// at startup via SetEventBus; reads are nil-safe so test contexts that
// never call SetEventBus simply skip publishing.
//
// A package-global is the right shape here because:
//
// - There is exactly one bus per process (the same instance powers
// the SSE subscriber endpoint and every publisher across the codebase).
// - Threading it through RunScan + TryStartScan + the Scheduler +
// every test caller would touch ~10 call sites without changing
// behavior at the boundaries that don't care.
// - Other Go stdlib packages use the same pattern (log.SetDefault,
// etc.) for process-wide singletons.
var (
busMu sync.RWMutex
bus *eventbus.Bus
)
// SetEventBus wires the live-event bus into the library package so scan
// runs emit scan.run_started / scan.run_finished events. Call once during
// process startup; subsequent calls overwrite the previous bus.
func SetEventBus(b *eventbus.Bus) {
busMu.Lock()
defer busMu.Unlock()
bus = b
}
// publishScanEvent broadcasts a scan lifecycle event. No-op when no bus
// has been set. Scan events are always broadcast (empty UserID) because
// only admins see the scan status card; non-admin clients' invalidation
// dispatcher will receive the event but won't have any provider keyed on
// scan status, so the receive is harmless.
func publishScanEvent(kind string, runID pgtype.UUID, data map[string]any) {
busMu.RLock()
b := bus
busMu.RUnlock()
if b == nil {
return
}
payload := map[string]any{"run_id": uuidToBusString(runID)}
for k, v := range data {
payload[k] = v
}
b.Publish(eventbus.Event{
Kind: kind,
UserID: "", // broadcast
Data: payload,
})
}
// uuidToBusString renders a pgtype.UUID in canonical 8-4-4-4-12 form.
// Inlined to avoid a back-edge dependency on internal/api or another
// helper-bearing package.
func uuidToBusString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
+4
View File
@@ -83,6 +83,7 @@ func RunScan(
return pgtype.UUID{}, fmt.Errorf("insert scan_run: %w", err)
}
logger.Info("scan run started", "id", row.ID)
publishScanEvent("scan.run_started", row.ID, nil)
var firstErr error
captureErr := func(stage string, stageErr error) {
@@ -228,5 +229,8 @@ func RunScan(
}
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
publishScanEvent("scan.run_finished", row.ID, map[string]any{
"error_message": errMsg,
})
return row.ID, firstErr
}
+64 -8
View File
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
@@ -21,22 +22,65 @@ type Reconciler struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
logger *slog.Logger
bus *eventbus.Bus
tick time.Duration
batch int32
}
// NewReconciler constructs a Reconciler with production defaults:
// 5-minute tick, batch size 50.
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger) *Reconciler {
// 5-minute tick, batch size 50. Pass nil for bus when no SSE publishing
// is desired (tests, or environments without the event stream enabled).
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
return &Reconciler{
pool: pool,
lidarrCfg: cfg,
logger: logger,
bus: bus,
tick: 5 * time.Minute,
batch: 50,
}
}
// publishCompleted broadcasts a request.status_changed event scoped to
// the original requester. No-op when bus is nil.
func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) {
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from lidarrrequests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
@@ -114,13 +158,17 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db
}
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedArtistID: artistID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
return err
if err != nil {
return err
}
r.publishCompleted(completed)
return nil
}
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
@@ -138,13 +186,17 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
}
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedArtistID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
return err
if err != nil {
return err
}
r.publishCompleted(completed)
return nil
}
func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
@@ -178,13 +230,17 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedTrackID: trackID,
MatchedArtistID: pgtype.UUID{},
})
return err
if err != nil {
return err
}
r.publishCompleted(completed)
return nil
}
func isNoRows(err error) bool {
@@ -121,7 +121,7 @@ func TestReconciler_MatchesArtistByMBID(t *testing.T) {
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -158,7 +158,7 @@ func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -200,7 +200,7 @@ func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) {
LidarrTrackMBID: trackMBID, TrackTitle: "Track One",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -234,7 +234,7 @@ func TestReconciler_NoMatchLeavesPending(t *testing.T) {
Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -278,7 +278,7 @@ func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) {
t.Fatalf("CompleteLidarrRequest setup: %v", err)
}
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -313,7 +313,7 @@ func TestReconciler_DisabledIsNoOp(t *testing.T) {
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -345,7 +345,7 @@ func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) {
Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
rec.tick = 25 * time.Millisecond
done := make(chan struct{})
@@ -399,7 +399,7 @@ func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) {
LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
@@ -428,7 +428,7 @@ func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) {
LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}

Some files were not shown because too many files have changed in this diff Show More