Compare commits

...

162 Commits

Author SHA1 Message Date
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 1dc298c111 chore(flutter): bump version to 2026.5.19+12 for v2026.05.19.2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:47:45 -04:00
bvandeusen e605335339 fix(player): remove custom favorite MediaControl — it killed the notification
Device logcat (Pixel 6 Pro / Android 16) showed audio_service throwing
on every state broadcast:

  java.lang.IllegalArgumentException: You must specify an icon resource
  id to build a CustomAction
    at com.ryanheise.audioservice.AudioService...

The #57 MediaControl.custom favorite makes audio_service build a
PlaybackStateCompat.CustomAction whose icon id resolves to 0 on real
builds; the exception aborts the ENTIRE media notification, so nothing
posts to the tray or the watch (emulator tolerated it). Not a
permission / PathParser / FGS issue — POST_NOTIFICATIONS was verified
granted. Pre-#57 there was no CustomAction, matching the regression.

Remove the custom favorite control; the notification is rebuilt with
only the standard transport controls (audio_service ships their icons).
customAction handler / refreshFavoriteControl left as harmless no-ops
to minimise churn. Like/favorite remains in-app + lock screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:19:42 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 01a1ac148e chore(flutter): bump version to 2026.5.19+11 for v2026.05.19.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:08:13 -04:00
bvandeusen bc34d96329 fix(player): request POST_NOTIFICATIONS; auto-minimize player on teardown
Device-surfaced on physical Android 13+ (worked on emulator):

A) The media notification never appeared because the app never
   requested POST_NOTIFICATIONS at runtime — the manifest declares it
   and the foreground service is correct, but Android 13+ denies it by
   default until asked. Add permission_handler ^12.0.1 and request
   Permission.notification once at startup (post-first-frame,
   Platform.isAndroid-guarded; no-op on <13 / once decided).

B) When the #52 idle/dismiss teardown nulled mediaItem while the full
   NowPlayingScreen was open, it stranded the user on an empty
   "Nothing playing." Scaffold. Now post-frame maybePop() so it
   auto-minimizes (the mini bar is already gone).

pubspec.lock + db.g.dart regenerated by CI/build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:03:31 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen eaddb2478a fix(ci): scope integration Postgres discovery to this job's network
`--filter name=integration` matched EVERY concurrent integration run's
Postgres service container. A dev push and the main-merge run overlap
on the shared act_runner daemon → 2 candidates → the "expected exactly
1" guard aborts (false failure; not a code defect).

Discover instead by intersecting networks: act_runner attaches the job
container and its service container to a shared per-job network, so
select the postgres that sits on a network this job container is also
on. The dev-compose container is skipped explicitly as before.

CI-only change; the released v2026.05.19.0 code is unaffected (a clean
re-run of the failed job passes — the failure was a concurrency race).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:50:04 -04:00
bvandeusen 53be834e89 Merge pull request 'v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration' (#52) from dev into main
Merge v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration (PR #52)
2026-05-18 21:42:48 -04:00
bvandeusen c681191b42 chore(flutter): bump version to 2026.5.19+10 for v2026.05.19.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:42:14 -04:00
bvandeusen 94871437dd feat(player): resume on media-button when session torn down (#448)
Fast-follow of #54. After the #52 idle/dismiss teardown, pressing play
on a headset / watch / lock screen did nothing (handler.play() was just
_player.play() with nothing loaded; the handler is Riverpod-agnostic).

- audio_handler: _resumeHook + setResumeHook(); play() is now async —
  when mediaItem == null and a hook is set it awaits the hook (which
  restores + plays) and returns, else _player.play()
- resume_controller: extract shared _loadAndRestore() (bool); _restore()
  keeps the paused launch path; new resumeFromMediaButton() restores
  then starts playback; start() registers it via setResumeHook

Recursion-safe (post-restore mediaItem != null so the re-play hits
_player.play()); no-op when nothing to resume / auth missing / a
session is already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:31:40 -04:00
bvandeusen 359a072937 test(playlists): playlist_card finds LucideIcons.ellipsis_vertical
#60 swapped Icons.more_vert -> LucideIcons.ellipsis_vertical in
playlist_card.dart; the widget test still asserted the old Material
icon (find.byIcon(Icons.more_vert)) and failed. Update both finders
+ import flutter_lucide.

Note: like_button_test.dart still references Icons.favorite but is
skip:true (gated on Fable #399) so it compiles and doesn't run;
flagged as stale to update when that test is unskipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:50:10 -04:00
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00
bvandeusen 84f16c25f6 feat(ui): Lucide migration unit 1 — dependency + heart (#60)
Design system mandates Lucide, not Material. Foundation before the
mechanical Icons.* sweep:

- pubspec: add flutter_lucide ^1.11.0
- shared/widgets/lucide_heart.dart: LucideHeart renders the verified
  lucide-icons/lucide heart path as outline (stroke) or filled, via
  flutter_svg, tinted by color — Lucide ships no filled heart, so the
  liked state fills the same Lucide silhouette (user-chosen approach)
- like_button: use LucideHeart instead of Icons.favorite/_border
- notification drawables re-derived from the verbatim Lucide heart
  path (border = stroke, filled = fill); separators spaced for
  Android pathData

Unit 2 (mechanical Icons.* -> LucideIcons.* sweep) follows once this
is CI-green. pubspec.lock regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:18:22 -04:00
bvandeusen 25ee54fca0 feat(player): surface playback errors via debounced SnackBar (#58)
_handlePlaybackError silently skipped a dead track (404 / decoder /
EOS / network drop) with only a debugPrint, hiding the signal that
tells a broken track from a flaky app.

- audio_handler: _playbackErrors broadcast stream; emit the failing
  track title (mediaItem.value?.title — correctly mapped post-#49)
  before the skip/pause
- playback_error_reporter (new): global scaffoldMessengerKey +
  reporter that buffers, 2s-debounces, and coalesces bursts into one
  SnackBar ("Couldn't play X — skipping" / "Skipped N unplayable
  tracks")
- app.dart: scaffoldMessengerKey on MaterialApp.router + postFrame read

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:39:58 -04:00
bvandeusen c659165218 fix(player): backtick doc-comment generics (analyze --fatal-infos)
`Future<dynamic>` in the customAction doc comment tripped
unintended_html_in_doc_comment (bare angle brackets read as HTML).
Wrap the code identifiers in backticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:00:43 -04:00
bvandeusen 0d80a113fa feat(player): notification favorite via MediaControl.custom (6c)
Adds a heart action to the media notification implemented as a custom
control + customAction handler — NOT setRating, which is broken
upstream (audio_service #376: onSetRating never fires from a
notification tap) and previously blanked the Pixel Watch.

- res/drawable/ic_stat_favorite{,_border}.xml: white 24dp vector hearts
- audio_handler: favorite MediaControl.custom in _broadcastState
  (icon/label toggle by LikeBridge state; kept out of
  androidCompactActionIndices so compact/lock + Wear transport are
  unchanged); customAction override (Future<dynamic>, matches base)
  toggles the like then re-broadcasts; refreshFavoriteControl()
- player_provider: cascade refreshFavoriteControl into the likedIds
  listener so liking from TrackRow/kebab/SSE flips the notification heart

Reliable on phone notification + lock screen; Wear/Auto display of a
non-transport custom action is platform-dependent (not a bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:37:20 -04:00
bvandeusen 5d37616d52 feat(player): downscale + preload external cover art (8a)
AudioServiceConfig handed full-res album art to the notification /
lock screen / Wear. Add artDownscaleWidth/Height: 300 + preloadArtwork
so external surfaces get a smaller, faster, lower-memory cover with a
warm first paint.

6b (notification tap-to-open) dropped: androidNotificationClickStarts
Activity already defaults to true, so the tap foregrounds the app;
deep-linking to now-playing isn't a config knob and was judged
disproportionate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:04:51 -04:00
bvandeusen 77a4a55522 feat(player): periodic PlaybackState refresh for smooth external scrubber
_broadcastState only set updatePosition on transitions, so the lock-
screen / Wear / Android Auto scrubber jumped in chunks (the in-app bar
uses positionStream and was fine). Add _positionBroadcastTimer: a 1s
periodic PlaybackState re-broadcast while actively playing so
updateTime/updatePosition stay fresh and external surfaces interpolate
smoothly. Idempotent (driven from _broadcastState, which the tick
itself calls — guarded against pile-up), cancelled when not playing
and in stop(). 6a: the notification progress bar now advances since
MediaItem.duration was already set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:46 -04:00
bvandeusen 41dd2892e3 feat(player): resume last session on launch (#54)
The #52 teardown clears the session when idle/dismissed, so the
headset / lock-screen play button had nothing to resume and the user
lost their place.

- track.dart: toJson() (round-trips fromJson)
- db.dart: CachedResumeState single-row snapshot, schema 9->10 + migration
- audio_handler.dart: queuedTracks getter
- player_provider.dart: restoreQueue() — configure + setQueueFromTracks
  + seek, no play (restores PAUSED)
- resume_controller.dart: restores last snapshot paused on launch;
  persists {source,index,position_ms,tracks} debounced on track
  change / pause and immediately on app teardown; never clobbers the
  saved snapshot when the queue is empty so a teardown stays
  recoverable; restore gated on auth + no active session
- app.dart: wired into postFrame

db.g.dart regenerated by CI per project convention. Deferred fast-follow:
media-button-when-fully-stopped re-init (needs a configure()-injected
callback; tracked on #54).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:58 -04:00
bvandeusen c80dc0b306 fix(player): const AudioSessionConfiguration.music() (analyze --fatal-infos)
AudioSessionConfiguration.music() is a const constructor; the earlier
pre-emptive drop of `const` tripped prefer_const_constructors under
flutter analyze --fatal-infos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:41 -04:00
bvandeusen 57ce3d2d0a feat(player): audio focus, interruptions & becoming-noisy
The Flutter client had no audio_session integration, so it didn't pause
for phone calls / other media, didn't duck for navigation prompts, and
kept blasting the phone speaker when earbuds were unplugged.

Add audio_session ^0.2.3 and configure AudioSessionConfiguration.music()
in MinstrelAudioHandler (best-effort, fully try-caught):
- becomingNoisy -> pause (no speaker blast on unplug/BT drop)
- interruption begin: duck -> lower volume; pause/unknown -> pause,
  remembering whether we were actively playing
- interruption end: duck -> restore volume; pause -> resume only if we
  paused it and the session isn't torn down (guards the idle-teardown
  -during-long-call edge; re-init is resume-last-session territory);
  unknown -> no auto-resume

pubspec.lock regenerated by build/CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:43:52 -04:00
bvandeusen 905f05c120 feat(player): tear down MediaSession when idle/dismissed
Nothing drove the audio_service session to a terminal state and the
notification is configured ongoing, so the Wear tile / lock screen /
notification lingered on a stale paused track indefinitely.

- stop() override: pause, broadcast idle, clear queue/mediaItem, then
  super.stop() so the foreground service + notification (and watch tile)
  tear down; in-app mini bar collapses in lockstep.
- onTaskRemoved(): keep playing if audio is active (standard media
  behaviour), otherwise stop so a dismissed-while-paused app doesn't
  leave a stale tile.
- 5-minute idle timer armed while paused or on a finished queue,
  cancelled on resume / new queue, re-checked at fire time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:38:42 -04:00
bvandeusen e68e1b10a6 fix(player+lidarr): mini-player sync race; durable approve + dedup
player: setQueueFromTracks fast-starts a single source at player-index 0
while the full queue is broadcast, so the transient currentIndexStream→0
emission clobbered the correct mediaItem with queue.value[0] (the first
track). Mini bar / playlist marker pinned to the wrong track until a
later index event (~the "passive ~30s recovery"). Track a logical-index
base so the player→queue mapping stays correct during the fill window;
also fixes the latent forward-fill auto-advance off-by-base.

lidarr #50: approving no longer fails when Lidarr is down. Approve
records the decision durably first, then best-effort adds; the
reconciler idempotently (re)sends unconfirmed adds every tick until they
stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map
Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or
expiry by design — Lidarr keeps trying, operator monitors.

lidarr #51: Create() is now idempotent — a non-terminal request for the
same MBID returns the existing row instead of inserting a duplicate.

Rewrites the obsolete LidarrUnreachable_503 test to assert the durable-
approve contract; threads a client factory into NewReconciler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:24:20 -04:00
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen b76ea66165 chore(flutter): bump version to 2026.5.18+9 for v2026.05.18.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:40:44 -04:00
bvandeusen 412b9e37eb test(coverart): no-MBID enrich settles 'none' (per canonical behavior)
Operator decision: the enricher is canonical. No MBID still runs the
provider chain (name-based providers — Deezer/Last.fm — resolve
without an MBID); if every provider returns ErrNotFound the row
settles cover/artist source 'none' at the current sources version
(re-eligible only when the registered provider set changes). It does
NOT skip-and-leave-NULL.

The two _NoMBID_LeavesNull tests predated the name-based providers
(0020 slice) and asserted the old skip→NULL contract. Updated:
- TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound
  (realistic MBID-only-provider-with-empty-MBID), expect source 'none'.
- TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry →
  allWere404 stays true → expect 'none'.

Last failing cluster from the CI-integration initiative; suite should
now be fully green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:27:01 -04:00
bvandeusen a86b7a5e07 test(coverart): DrainsNullSourceOnly — stamp id2 'none' at current version
The test marked id2 'none' via SetAlbumCover, which (covers.sql:42)
does NOT set cover_art_sources_version. ListAlbumsMissingCover treats
'none' AND version != current as eligible, so id2 (version 0, current
1) was wrongly drained → processed=2. The comment's intent ("'none'
with current version → not drained") requires SetAlbumCoverWithVersion
(albums.sql) stamped with the live GetCurrentSourcesVersion — the same
value EnrichBatch compares against. Test-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:22:44 -04:00
bvandeusen 5e91efe695 test(coverart): fix registry-wipe + stale provider signature
Two distinct pre-existing test bugs in the last failing cluster:

1. newTestEnricher() called resetRegistryForTests() itself, wiping the
   fake providers callers Register() right before calling it (its own
   doc says callers register first and reconcile() picks them up). The
   ~8 TestEnrichArtist_* failures (source stayed NULL, no thumb
   written) all stem from reconcile() seeing an empty registry. Remove
   the internal reset; make every caller that lacked one own the
   registry lifecycle explicitly (resetRegistryForTests + t.Cleanup):
   SidecarFound, NoSidecarNoMBID, AlreadySidecar_NoOp,
   DrainsNullSourceOnly.

2. apiTestAlbumProvider.FetchAlbumCover had a stale signature
   (context, string) predating the AlbumRef refactor; it no longer
   satisfied coverart.AlbumCoverProvider, so the p.(AlbumCoverProvider)
   capability assertion failed and TestAdminListCoverSources got
   supports=[]. Fix the param to coverart.AlbumRef.

Test-only. (A1/A2 were correct; they unmasked these. Any residual
album-enricher semantics failures will be root-caused from the next
clean run, not bundled speculatively.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:36 -04:00
bvandeusen 47572b2e95 feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.

- ArtistSuggestion/SeedContribution model mirroring web types, with
  attributionText() matching web's "Because you liked/played X[, Y, and
  Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
  (image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
  name + attribution + Request, reusing the existing createRequest +
  mutation-queue-replay flow with optimistic hide); typing → Lidarr
  search replaces; clearing → suggestions return. Mirrors web exactly.

Flutter-only; server endpoint unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:54 -04:00
bvandeusen 53a02322fb fix: A2 (relax art-source CHECK) + D + E integration residual
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.

D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).

E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
  canonical code is "unauthenticated" (operator decision). Change the
  code; drop the now-dead "auth_required" web error-copy key
  ("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
  but only injected withUser() context → 401. Like every other api
  handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
  prefixes) but POSTed "existing" → no collision; POST the prefixed
  name.
- me_timezone test decoded a top-level {"code"} but the envelope is
  {"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
  handler behavior); supply the required defaults in the bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:30 -04:00
bvandeusen 75163ea483 test: correct the A regression + finish B residual (coverart boot, stale system_test)
A redo: the prior commit truncated cover_art_sources_meta, but
SettingsService.reconcile() only READS that singleton (seeded once by
migration 0018) and never recreates it → ~45 coverart/api tests hard-
failed "get current sources version: no rows". Correct reset: keep
cover_art_provider_settings in the truncate set (boot idempotently
re-UpsertProviderSettings), drop cover_art_sources_meta from it, and
instead `UPDATE cover_art_sources_meta SET current_version = 1` so the
row survives while cross-test version accumulation is cleared.

B residual (exposed once the play_events FK fix let these run):
- seedQuarantine used reason 'test-hide', invalid for the
  lidarr_quarantine_reason enum (bad_rip/wrong_file/wrong_tags/
  duplicate/other) → use 'other'.
- TestBuildSystemPlaylists_SufficientActivity predated #411/#352: the
  variant switch errored on the 5 new seedless mixes and capped
  track_count at 25. Accept deep_cuts/rediscover/new_for_you/
  on_this_day/first_listens as seedless; raise the cap to 100.

Test/test-harness only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:18 -04:00
bvandeusen d212621eaa test: fix 4 integration-suite root causes surfaced by CI (C+A+B+F)
Latent failures exposed now that integration tests run in CI (#339).
All test/test-harness only — no production code changes.

A (dbtest.ResetDB): also truncate cover_art_provider_settings +
cover_art_sources_meta. The monotonic source-version counter (seeded 1
by 0018) accumulated across internal/coverart tests → CurrentVersion=4
want 1, key-only-bump assertions, enricher source skips. SettingsService
re-seeds both at boot, so a truncated start is the correct fresh state.

B (playlists system_test.seedPlayEvent): inserted play_events with a
random session_id → play_events_session_id_fkey violation (7 tests).
Create the parent play_sessions row in the same statement (CTE).

C (similarity worker_integration_test.newTestWorker): built the client
with only BaseURL. Post-4fca0e6 similarity hits the Labs API via
LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api
and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub.

F (library scanner_test): NOT a flake — deterministic. Synthetic
ID3-only MP3s have no decodable duration; the scanner intentionally
won't skip duration_ms=0 rows (retries duration backfill), so every
re-scan reported Updated not Skipped. Seed duration_ms>0 before the
second scan so the mtime-based incremental-skip path under test is
actually exercised. Production scanner behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:32:42 -04:00
bvandeusen 5a048cbea2 fix(ci): serialize integration test packages (-p 1) to stop TRUNCATE deadlocks
First CI integration run proved the act_runner pattern works (service
discovery, migrate, exactly-one guard all functioned) but ~150 tests
failed with `dbtest.ResetDB truncate: deadlock detected (40P01)` plus
cascading FK/dup-key symptoms. Root cause: `go test ./...` runs package
binaries concurrently (default -p = NumCPU); every integration package
TRUNCATEs the single shared minstrel_test DB, so concurrent truncates
deadlock and half-seeded fixtures violate FKs. The documented local
invocation is `go test -p 1 ./...` for exactly this reason. Serialize
package execution in both the CI step and `make test-integration`.

Genuine (non-concurrency) failures will remain after this — never caught
before because integration tests never ran in CI. Triaged next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:22:06 -04:00
bvandeusen 760b4a7c6c feat(cli,ci): admin reset-password + migrate subcommands; test-DB isolation + CI integration job
#321 — `minstrel admin reset-password [-user admin] [-password X]`:
loads config, updates BOTH password_hash (bcrypt) and subsonic_password
(plaintext, for Subsonic t+s) so neither auth path is left stale;
generates+prints a strong password when -password is omitted. Recovers
a locked-out operator without DB surgery. Subcommand dispatch added to
main.go (os.Args switch before flag-parse; server path untouched) plus
a `minstrel migrate` subcommand exposing db.Migrate standalone.

#339 — integration tests no longer truncate the dev DB:
- deploy/initdb creates minstrel_test on a fresh compose volume;
  `make test-integration` ensures it idempotently and points
  MINSTREL_TEST_DATABASE_URL at minstrel_test.
- docker-compose.yml + README updated.

CI integration job (test-go.yml): the prior workflow only ran
`go test -short -race` with no DB, so the entire integration suite
silently t.Skip'd — "CI green" never covered API/db/scanner. New
`integration` job runs the full `go test -race` against an ephemeral
Postgres service, using the act_runner shared-daemon pattern: no
published ports, discover the service container by job-name filter via
the docker socket, reach it by bridge IP, hard exactly-one assertion +
dev-compose-name reject (a wrong target would truncate real data),
TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the
quick gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:36:36 -04:00
bvandeusen a7bea43a13 feat(discover): on-demand Lidarr artist art for suggestions
Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).

- suggestionView gains image_url (omitempty); handler resolves it via
  bounded-concurrency Lidarr LookupArtist, best-effort, never fails
  the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
  empty → existing placeholder. (No inline TheAudioDB fallback — it's
  externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
  DiscoverResultCard (already supports imageUrl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:16:56 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen 9ffe33a6f2 chore(flutter): bump version to 2026.5.16+8 for v2026.05.16.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:58:19 -04:00
bvandeusen 3b142e5332 test(server): drop removed coverArtBackfillCap arg from New() calls
Follows 005965d (#388), which removed the coverArtBackfillCap param
from server.New. server_test.go is in package server so it calls New()
unqualified — missed by the signature-caller grep. Updated all 6
positional calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:45:24 -04:00
bvandeusen 005965d6de feat(coverart): #388 remove global cover-art backfill cap
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.

- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
  EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
  disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
  MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
  server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
  unbounded; response {queued:int} → {started:bool} (the count is
  unknowable synchronously for a fire-and-forget drain). Web copy +
  client type + Go/web tests updated to match.

No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:41:21 -04:00
bvandeusen 4fca0e66cb fix(listenbrainz): similarity hits Labs API, not the 404'ing main API
Root cause of zero LB recommendations (every similar-recordings AND
similar-artists call returned HTTP 404, worker logs):

- Wrong host/path: client called
  api.listenbrainz.org/1/explore/similar-{recordings,artists}/{mbid}.
  /explore/... is a WEBSITE route, not an API endpoint — it 308s then
  404s. Similarity datasets live on the separate Labs API.
- Invalid algorithm: the hardcoded
  session_…_session_30_…_limit_100_filter_True_… is not a permitted
  Labs enum member (400s) regardless of host.

Verified against the live Labs API:
  GET labs.api.listenbrainz.org/similar-recordings/json
      ?recording_mbids=<mbid>&algorithm=<algo>
  GET labs.api.listenbrainz.org/similar-artists/json
      ?artist_mbids=<mbid>&algorithm=<algo>
  algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30
  → 200 for both. Response field names (recording_mbid/artist_mbid/
  name/score) already match the existing structs — parsing unchanged.

- Add defaultLabsBaseURL + Client.LabsBaseURL (separate from the main
  BaseURL; scrobble submission still uses api.listenbrainz.org).
- Drop the count/limit query param — result size is encoded in the
  algorithm name (limit_50); caller still applies its own top-K.
- Tests: newTestClient sets LabsBaseURL; the two *_LimitParamSet tests
  become *_MbidParamSet (assert the Labs path + mbid query param).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:50:32 -04:00
bvandeusen 4d2aebe3ed test(similarity): update default-batch assertion 5→25
Follows ca1bc5a, which raised the worker batch default. The defaults
test pinned the old value; align it with the intended new default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:51:22 -04:00
bvandeusen ca1bc5af62 perf(similarity): worker batch 5→25; track-MBID backfill uncapped
- similarity.Worker batch 5→25 (tracks AND artists per 1h tick). At
  5/h a freshly-scrobbled library took days to build a usable
  similarity pool; 25/h converges in hours, still well under
  ListenBrainz rate limits (429 aborts the tick).
- scanrun Stage 2b track backfill now runs unbounded (-1) instead of
  reusing the album backfill's 5000 staged cap. It's a one-time
  whole-library heal with no progress UI; a cap just left tracks.mbid
  partially NULL (3634/18056 after one pass) until several future
  scans caught up, re-reading untagged files each time. One uncapped
  pass converges; later scans only re-read the remaining NULL rows.
  Album backfill keeps its 5000 cap (it has staged scan_runs UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:24:45 -04:00
bvandeusen e2432caa65 fix(library): extract recording MBID → tracks.mbid; unblock LB similarity
tracks.mbid was 100% NULL: the scanner only extracted album + artist
MBIDs, never the recording MBID. The ListenBrainz similarity worker is
gated on tracks.mbid IS NOT NULL, so track_similarity could never
populate — starving For-You/radio's strongest candidate source
(lb_similar) on every library.

- mbids.go: add extractRecordingMBID (mbz.Recording / Picard
  musicbrainz_recordingid). Separate fn so extractMBIDs' signature +
  unit tests stay untouched.
- scanner.go: persist recording MBID via UpsertTrack (heals on the
  file_path conflict, so re-scans backfill for free).
- BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as
  scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by
  BackfillCap, log-only progress).
- migration 0029: tracks_mbid_unique (0002, written when the column
  was always NULL) wrongly assumes one MBID == one track. A recording
  appears on multiple releases, so rows legitimately share a recording
  MBID. Replace with a non-unique partial index. Zero-risk: column is
  100% NULL at migration time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:49:38 -04:00
bvandeusen 2e7b81fdfe fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks
For-You silently vanished after ~7 days of not listening (seed query
required a non-skip play in the last 7 days) and capped at ~40 on
self-hosted libraries with no ListenBrainz similarity data.

- PickTopPlayedTracksForUser: tiered seed — last 30d top plays, else
  all-time top plays, else liked tracks. For-You only disappears now
  if the account has zero plays AND zero likes.
- produceForYou uses deeper candidate source limits (raised random/
  tag/similar K) so it reaches ~100 even with empty lb_similar /
  similar_artists; richer when LB enrichment is present.
- Rediscover: tiered — 6-month-dormant, else ≥5-play 30-day-dormant.
- On This Day: floor 60→30 days, window ±7→±10 doy; still skips
  cleanly (no rows → no playlist) on insufficient history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:48:20 -04:00
bvandeusen ec0cc37bc9 Merge pull request 'Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints' (#49) from dev into main 2026-05-16 00:32:25 -04:00
bvandeusen 1e2c486356 fix(playlists): allow the 5 discovery-mix variants in DB constraints
HOTFIX for v2026.05.15.0. R3 added deep_cuts/rediscover/new_for_you/
on_this_day/first_listens producers + registry entries but not their
system_variant values to the playlists_kind_variant_consistent /
playlists_seed_consistent CHECK constraints (0021's whitelist).
insertSystemPlaylist for any new mix → SQLSTATE 23514, and since
BuildSystemPlaylists is one all-or-nothing txn that aborts the
ENTIRE build — For-You/Discover refresh 500s and the daily lazy
build fails too. System playlists are fully broken in prod.

Migration 0028 drops + re-adds both constraints with all five new
seedless variants (mirrors the 0021 drop-and-readd pattern).
CHECK-only — sqlc/dbq unaffected (regen produced no drift).

Bumps to 2026.5.15+7 for the v2026.05.15.1 patch release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:39:21 -04:00
bvandeusen e772938a3b Merge pull request 'Release v2026.05.15.0 — system playlists v2, offline cache rework, CI speedup' (#48) from dev into main 2026-05-15 23:15:48 -04:00
bvandeusen 0c2b86e736 chore(flutter): bump version to 2026.5.15+6 for v2026.05.15.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:15:10 -04:00
bvandeusen ce36760819 feat(playlists): #417 — "Refreshed …" subtitle on system tiles
Closes the last buildable item of the #411 system-playlists-v2
umbrella. System playlists atomic-replace on rebuild, so created_at
(already on the wire — no server change) is the last-rotated time.
Surface it as a small tile subtitle so users see how fresh a mix
is: "Refreshed just now / today / yesterday / N days ago / Mon D".

- web PlaylistCard: refreshedLabel() + a muted footer line, shown
  only when system_variant != null. Unparseable/empty timestamp →
  suppressed (web test fixtures use created_at:'' so no test churn).
- flutter PlaylistCard: mirrored _refreshedLabel() + subtitle under
  the system badge for isSystem playlists.

Friendly wording deliberately distinct from HistoryRow's "m/h ago";
per-surface helper per the project's existing relative-time
convention. CI-pending; closes with the umbrella on device-check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:03:41 -04:00
bvandeusen 2d9775244c feat(offline): #427 S4b — offline pools on Home beside system tiles
Operator's model: offline, surface the cache-backed pools right
where the (now play-disabled, S4a) system playlists sit, so Home
still has something to play.

- ShuffleSource gains recentlyPlayed() (cache by lastPlayedAt desc,
  liked included) and liked() (cache ∩ liked set); shared _refs()
  materializes ordered ids from cached_tracks/artists/albums.
  Shuffle-all reuses the same recency walk.
- Home Playlists row: when offlineProvider is true, prepend two
  tiles — "Recently played" / "Liked" — sized to PlaylistCard.
  Tap → shuffle+play that pool from cache; empty → snackbar.
  System tiles still render (play disabled per S4a) beside them.
- offline=false (online + all widget tests) → no extra tiles;
  placeholder-count tests unaffected.

Closes the S4 thread of the #427 umbrella (S4a + S4b).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:12:51 -04:00
bvandeusen 035b000bb0 fix(analyze): drop unused drift import in shuffle_source
Query-builder methods come through the generated AppDb, not the
drift package directly — the import was dead. (#427 S4a)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:44:46 -04:00
bvandeusen a59967be20 fix(offline): move Shuffle all from Home to Library only
Operator: Shuffle-all belongs in the Library view, not the Home
app bar. Moved the shuffle IconButton to LibraryScreen's app bar
(same behavior — online server-random / offline cache-union via
shuffleSourceProvider); reverted Home's app bar to the original
MainAppBarActions-only and dropped the now-unused imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:41:48 -04:00
bvandeusen a5e2abb8c4 feat(offline): #427 S4a — Shuffle all + offline system-playlist gate
Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online  → GET /api/library/shuffle?limit=N (new): N random
  library tracks server-side, per-user quarantine filtered
  (ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
  (audio_cache_index ∩ cached_tracks, names from cached_artists/
  albums) — a UNION over liked AND recently-played, since the
  two-bucket split is storage-only and never filters playback.

Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.

playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).

S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:37:29 -04:00
bvandeusen a4f293b7cf ci(flutter): build debug APK on main only, not every dev push
The debug APK step dominated the run (~7m32s of ~10m) and ran on
every dev push, but the operator dev-tests via a local Android
Studio build, not the CI artifact. Gate the debug APK + its
artifact upload to main pushes only: dev = analyze+test+codegen
(~3min); main = + debug APK as the native-build safety net
(Gradle/manifest/plugin breakage analyze+test can't catch) before
any release tag; tags = signed release APK (unchanged).

Note: the bulk of that 7m32s was the flutter-ci runner image
re-installing NDK 28.2.13676358 + build-tools 35 + platform 35/36
+ cmake 3.22.1 every run (image baked platform-34/build-tools-34,
stale vs the bundled Flutter's requirements). Baking those into
CI-Runner/CI-flutter/Dockerfile is the larger win and also speeds
release builds — tracked separately (operator-side infra, not in
this repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:20:10 -04:00
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00
bvandeusen a0aea00667 feat(offline): #427 S1 — reachability offline marker
offlineProvider: a Notifier<bool> driven by a periodic /healthz
probe. Offline after N=3 consecutive failed probes; recovers on
the first success. Slow heartbeat when online (30s), faster when
offline (10s) so recovery is noticed quickly. 5s initial delay for
provider warmup; optimistic (false) until proven otherwise.

Deliberately NOT coupled to connectivityProvider — subscribing to
that StreamProvider eagerly mounts its 2s timeout and leaks a
pending Timer through widget tests (the MutationReplayer bug).
/healthz failing already covers interface-down. No .timeout()
wrapper either (dio's own timeouts bound the probe) so the only
Timers are the tracked initial+periodic, both cancelled via
ref.onDispose — the proven smoke-safe shape.

Wired in app.dart postFrame to start the poller. No UI yet; S4
gates system-playlist play + Shuffle-all on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:13:55 -04:00
bvandeusen c7ee0871a5 feat(playlists): #411 R3 — five discovery mixes (#419-423)
Each is one candidate query + one registry entry; zero client work
(R2 made tiles/refresh/shuffle generic, all are singleton kinds so
web's server `refreshable` flag and Flutter's derived getter both
light up automatically).

- deep_cuts (#419): <=2-play tracks from liked / heavily-played
  artists; diversity-capped.
- rediscover (#420): >=5-play tracks not heard in 6 months, by
  historical affection.
- new_for_you (#421): tracks from albums added <=30d whose artist
  the user likes/plays; album-coherent (no cap).
- on_this_day (#422): tracks played within ±7 day-of-year in prior
  windows (>60d ago), weighted by play count.
- first_listens (#423): never-played albums, tiered liked-artist →
  played-artist → rest; album-coherent.

system_mixes.go producers mirror the Discover model (SQL gives the
ranking; finishMix caps+truncates to 100 to match For-You/Discover
shuffle depth; album-coherent mixes skip the cap). Builder query
failure is non-fatal (logged, yields no playlist) like Discover.
Existing system_test existence checks are unaffected.

Closes the #411 system-playlists-v2 umbrella's new-types thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:55:27 -04:00
bvandeusen b9accf6934 style: gofmt playlists.go after Refreshable field add (#411 R2)
The commented Refreshable field broke gofmt's struct-tag column
alignment in playlistRowView. Pure formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:33:52 -04:00
bvandeusen 222742e368 test(api): replace per-kind refresh tests with generic system tests
go vet broke because playlists_{discover,foryou}_refresh_test.go
referenced the handlers/types deleted in R2 (d67c0de). Consolidated
into playlists_system_test.go covering the generic
/playlists/system/{kind}/{refresh} endpoint: for_you + discover
200/shape, non-singleton & unknown kind → 404, no-auth → 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:29:27 -04:00
bvandeusen d67c0de596 refactor(playlists): #411 R2 — generic registry-driven system endpoints
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.

Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
  exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
  GET /api/playlists/system/{kind}/shuffle ({kind} = raw
  system_variant). Non-singleton/unknown kind → 404. Deleted
  playlists_{foryou,discover}_refresh.go and the per-kind shuffle
  wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
  the refresh affordance generically without hardcoding kinds.

Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
  systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
  playlist.refreshable; label is "Refresh {name}". Tests reworked;
  obsolete refresh-foryou/discover api tests deleted.

Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
  songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
  detail screen gate kebab/Regenerate/source-tagging on refreshable
  so songs_like_artist plays via get() (no by-kind endpoint).

Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:21:09 -04:00
bvandeusen e3957b8eed fix(lint): rename unused now param in produceDiscover to _
revive unused-parameter: produceDiscover keys off dateStr, not now,
but must keep the uniform systemPlaylistProducer signature. Blank
the unused param (param names don't affect func-type identity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:33:05 -04:00
bvandeusen 1379595e82 refactor(playlists): #411 R1 — system-playlist kind registry
Behavior-preserving prep for the new mix types. Extracts the three
inline candidate computations in BuildSystemPlaylists into
producers (produceForYou / produceSeedMixes / produceDiscover) and
drives the build off a systemPlaylistRegistry. The shared
machinery (run-claim guard, atomic delete+insert tx, post-commit
collages) is now generic over a []builtPlaylist.

Fatal-vs-skip error semantics unchanged: a base query failure
(PickTopPlayedTracksForUser, PickSeedArtists) still aborts the
whole build; candidate-load / per-seed-artist / Discover-bucket
failures are still logged and just yield fewer playlists.
Materialize order (for_you, songs_like_artist, discover) is
unchanged and functionally irrelevant.

No API/client/schema change — CI's system/foryou/service tests
verify For You / Songs-like-X / Discover parity. Adding a new mix
is now: a producer + one registry entry + its candidate query.

Next (R2): generic /api/playlists/system/{kind}/{refresh,shuffle}
off the registry; then the new kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:25:54 -04:00
bvandeusen a571282031 feat(flutter): #426B client — offline play capture via mutation queue
Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.

- EventsApi.playOffline: single timestamp-preserving call → the new
  /api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
  - _beginTrack captures start context + fires live play_started;
    the server id is adopted only if it lands while still on-track.
  - position progress gated on the tracked track id so a track
    change can't clobber the finishing track's last values.
  - _closeCurrent: if a server id registered, attempt the live
    ended/skipped and fall back to the offline queue on failure; if
    no id (offline start) enqueue the completed play directly. The
    server applies the canonical skip rule, so the offline payload
    only carries duration.
  - app paused/detached closes durably via the queue (survives a
    process kill; a teardown POST would not).

Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:19:11 -04:00
bvandeusen 47aa178850 feat(playevents): #426B server — RecordOfflinePlay + /api/events play_offline
Server half of the offline-replay capture. New writer path
RecordOfflinePlay: writes a complete start+end play in one txn from
a caller-supplied `at` + duration_played_ms, applying the spec §6
skip rule (same AND-of-thresholds as RecordPlayEnded) and threading
`source` so #415 rotation advances for system-playlist plays just
like the live path. Generalizes RecordSyntheticCompletedPlay (which
hard-codes full completion). duration clamped to [0, track len].

New /api/events type "play_offline" → handleEventPlayOffline:
validates track + duration, reuses the existing req.At parse so the
play lands on the original timeline, not replay time. Subsonic
shim + live 3-call lifecycle untouched.

Flutter half next: EventsApi.playOffline, a play.offline
MutationQueue kind, and PlayEventsReporter capturing the completed
play + enqueuing it when the live calls have no server id / fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:14:21 -04:00
bvandeusen 8652d7124e fix(web): #426 classify in-queue track end as play_ended, not skip
The events dispatcher closed every prior open row as play_skipped on
any track-id change. Server-side RecordPlaySkipped force-sets
was_skipped=true regardless of completion, so a queue played start
to finish reported tracks 1..N-1 as skips — inflating
recommendation skip-ratios (-skipRatio*SkipPenalty) and degrading
For You / Discover quality for web listeners.

Now: on track change, close the prior row as play_ended if that
track reached ~its duration (3s tolerance, matching the Flutter
PlayEventsReporter), else play_skipped at the real last position.

Race fix: the store synchronously resets position/duration to 0 on
track change, so reading lastPositionMs at change-time would see 0
and misclassify. Track per-open-row state (openReachedEnd,
openLastPositionMs, openDurationMs) updated ONLY while the open
track is current — a track change can't clobber them before the
close branch runs.

Brings web wire behavior back in line with Flutter. Test added:
auto-advance after reaching duration → play_ended, never skipped;
existing mid-track-skip and pause-at-end tests still hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:45:11 -04:00
bvandeusen 3054e8702b feat(flutter): #415 stage 3 — play-events reporter + rotation wiring
The Flutter client previously reported NO plays — mobile listening
never reached play_events, so history, recommendation scoring,
ListenBrainz scrobbles, and #415 rotation all missed mobile entirely.
Operator chose to close that gap properly as part of Stage 3.

New:
- EventsApi (api/endpoints/events.dart): play_started/ended/skipped.
- PlayEventsReporter (player/play_events_reporter.dart): state
  machine over (track id, playing) mirroring the web dispatcher.
  Persists an opaque client_id in secure storage. Deliberate
  divergence from web: a track change inside a queue is classified
  ended-vs-skipped by whether the prior track reached ~its duration
  (3s tolerance), instead of web's blanket "track change = skip"
  which would mark every naturally-finished in-queue track a skip
  and dilute recommendation skip-ratios — the exact failure mode
  that motivated doing this properly. Fail-safe: no-ops when there's
  no audio handler (tests / no-audio env). App-lifecycle paused/
  detached closes an open row as a best-effort skip (web pagehide
  parity). Wired in app.dart postFrame.
- PlaylistsApi.systemShuffle(variant): GET the rotation-aware order.

Wiring:
- audio_handler: _queueSource carried through setQueueFromTracks
  (source param); preserved across internal skipToQueueItem rebuild.
- player_provider.playTracks: source param → setQueueFromTracks.
- PlaylistCard: system playlists fetch systemShuffle and play as-is
  tagged with source (no client shuffle — server already ordered).
- playlist_detail_screen: header Play + per-track tap tag source for
  system playlists so rotation advances from any entry point.

Known/flagged separately: the web dispatcher likely has the same
false-skip-on-advance issue; not fixed here to keep #415 scoped and
clients' wire behavior comparable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:15:32 -04:00
bvandeusen 179519689b feat(web): #415 stage 3 (web) — rotation-aware system playlist play
Web half of Stage 3. System-playlist tile play now:
- calls the new GET /api/playlists/system/{variant}/shuffle endpoint
  (rotation-aware order from the server) instead of getPlaylist;
  plays the returned order AS-IS — no client Fisher-Yates, since
  the server already ordered it. Supersedes #413's client shuffle
  for system playlists specifically; user playlists keep getPlaylist
  + stored order.
- tags the queue with the system variant. The player store carries
  _queueSource; the events dispatcher includes `source` on
  play_started so the server advances that playlist's rotation.

User playlists are unchanged (getPlaylist, plain playQueue, no
source). Tests updated: For-You play hits systemShuffle (not
getPlaylist/refresh) and passes source:for_you; user play uses
getPlaylist + plain playQueue with no source.

Flutter half is blocked — the Flutter client has no play-event
reporting at all (no /api/events POST, no scrobble), so there's no
play_started to attach `source` to. Surfacing that as a separate
decision rather than silently scope-exploding #415.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:57:00 -04:00
bvandeusen e43281d1d0 feat(playlists): #415 stage 2 — rotation-aware shuffle endpoint
GET /api/playlists/system/{discover,for-you}/shuffle returns the
caller's system playlist with tracks ordered: unplayed-this-rotation
first (shuffled), then already-heard (shuffled). When the whole
snapshot has been heard, ResetRotationState fires and the full list
reshuffles fresh.

Option A (operator's choice): a separate, intentionally-uncached
endpoint. The cached GET /api/playlists/{id} detail path stays pure
for "open to view"; this varies per play. Same JSON shape as the
detail GET so Stage 3 clients reuse track parsing with no new model.

Two explicit static routes per variant mirror the refresh handlers
and avoid chi static-vs-param ambiguity under /playlists/system/.
Empty/absent snapshot → 200 with empty track list (nothing to play,
not an error). Rotation reset failure is non-fatal — still returns a
playable reshuffled list.

No client wiring yet — Stage 3 makes web + Flutter call this on the
play/tile gesture and send `source` on play_started. Handler-level
test deferred to Stage 3 (needs the full service+pool harness; the
end-to-end path is exercised there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:47:57 -04:00
bvandeusen d2a0b7d780 feat(playlists): #415 stage 1 — rotation-state schema + play ingest
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).

Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
  from. 'for_you' / 'discover' feed rotation; NULL for library /
  user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
  played_track_ids uuid[], rotation_started_at, updated_at): the
  per-(user,kind) set of already-heard tracks this rotation.

Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
  is now a thin source="" wrapper so the frozen Subsonic shim is
  untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
  track to rotation state (AppendRotationPlayed keeps the array a
  set via the conflict CASE).
- /api/events play_started accepts an optional "source".

No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.

Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:40:17 -04:00
bvandeusen 69569a5c2b feat(playlists): expand For You to 100 tracks (Discover parity)
Closes Fable #414. forYouHeadN/forYouTailN go 12/13 → 50/50 so the
For-You snapshot is 100 tracks, matching Discover. Motivated by the
shuffle-on-play default that just shipped (#413): a 25-track shuffle
pool repeats fast; 100 makes re-plays within a day feel varied.

pickHeadAndTail already degrades gracefully when the candidate pool
is too thin for a full head/tail split (returns top-N-by-score),
mirroring how Discover returns <100 when its buckets are thin — no
new edge-case handling needed. No build-path test asserts the
For-You total; pickHeadAndTail unit tests pass their own head/tail
values so they're unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:32:55 -04:00
bvandeusen 45c72993f3 feat(flutter): replace Download with Regenerate on system playlist detail
Per operator decision: in the playlist detail header, system
playlists (For You / Discover) now show a Regenerate button where
user playlists keep Download. Offline-download is intentionally
dropped for system playlists — operator chose the literal swap.

- Regenerate calls PlaylistsApi.refreshSystem(variant), invalidates
  playlistsListProvider (home row tile rebinds to the rotated UUID),
  and pushReplacement's to /playlists/<newId> so the open detail
  screen rebinds instead of 404-ing on the stale id.
- Null id (empty library) and errors surface as snackbars.
- User playlists are unchanged (Download + Play).

The home-card kebab (#416, 7a04370) stays — web has refresh in both
the detail view and the home tile, so this matches web parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:55:37 -04:00
bvandeusen 7a0437087a feat(flutter): refresh kebab on system playlist cards (#416 parity)
Closes the Flutter half of Fable #416. Web got a generalized
system-playlist refresh kebab in d12afda; this brings Flutter to
parity instead of leaving the affordance web-only.

- PlaylistsApi.refreshSystem(variant): POST
  /api/playlists/system/{for-you|discover}/refresh, maps the
  underscore model variant to the hyphenated route segment,
  returns the rotated playlist id.
- PlaylistCard: top-right PopupMenuButton on system playlists
  with a context-labelled "Refresh For You" / "Refresh Discover"
  item. Calls refreshSystem, invalidates playlistsListProvider
  (which reconciles the rotated UUID + new tracks), snackbars
  the result. ScaffoldMessenger captured pre-await.
- Tests: kebab present for system, absent for user playlists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:35:55 -04:00
bvandeusen d12afdad6e feat(playlists): system playlists default to shuffle on tile play
Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).

Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
  03:00 user-local snapshot is what plays now. Stops burning server
  compute on every press and stops swapping the playlist out from
  under the user.
- Generalize the kebab affordance to render for any system playlist
  (was Discover-only). Adds "Refresh For You" as an explicit
  replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
  the queue when shuffle:true. PlaylistCard passes shuffle:true for
  any non-null system_variant.

Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
  starting index and enables AudioServiceShuffleMode.all after
  setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.

User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:55:57 -04:00
bvandeusen 33b11a3b3d feat(settings): About section with version + force update check
Adds an About card to Settings that shows the installed version
(version+build from PackageInfo), the latest known version from
clientUpdateProvider, and a "Check for updates" button that
invalidates the provider to force a fresh poll. When an update is
available, surfaces an Install CTA that reuses the same installer
flow as the top banner.

The existing banner (shouldShowUpdateBannerProvider) is unaffected
— it gates on per-version dismissal, while the About section
always reflects the current provider state regardless of dismissal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:26:22 -04:00
bvandeusen 29fee5aa37 Merge pull request 'Release v2026.05.14.0 — player polish, CacheFiller, offline mutation queue' (#47) from dev into main 2026-05-15 01:17:44 +00:00
bvandeusen 02336967b4 chore(flutter): bump version to 2026.5.14+5 for v2026.05.14.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:17:14 -04:00
bvandeusen 452e29bc59 fix(analyze): restore connectivity_provider import for drain()
Removed in f6ee837 thinking it was unused, but drain() still
reads connectivityProvider.future to gate replay attempts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:01:55 -04:00
bvandeusen f6ee837be6 fix(test): drop eager connectivity listener in MutationReplayer
ref.listen(connectivityProvider, …) at start-time mounted the
StreamProvider immediately, which kicked off checkConnectivity()
with a 2s timeout. In tests that never reach the auth state
(smoke_test cold-launch path), that Timer leaked past widget tree
dispose and tripped the still-pending-timer assertion.

Drop the edge trigger — the 3s initial + 1min periodic + post-
enqueue nudge already cover the drain paths. Worst case on
reconnect is ~60s extra latency before the queue drains, which
is acceptable for an offline-resilience layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:57:59 -04:00
bvandeusen d27dd69bfc fix(test): cancel one-shot Timers on CacheFiller / MutationReplayer dispose
Smoke test failed: "A Timer is still pending even after the widget
tree was disposed." Both workers fired their initial-delay Timer
via `Timer(duration, _sweep)` and stored only the periodic ticker
in the cancellable field — the one-shot Timer leaked past dispose
and tripped the test framework's invariant check.

Track both as _initialTimer + _intervalTimer; cancel both in
dispose(). Behavior is unchanged in production (ref.onDispose only
fires on process death normally); this is purely a test-harness
fix.
2026-05-14 18:38:38 -04:00
bvandeusen b991fde3fe fix(flutter): drop unnecessary String? casts on nullable map reads 2026-05-14 18:31:57 -04:00
bvandeusen 335940cf23 feat(cache): outbound mutation queue for offline-resilient REST
User intent (likes, hides, playlist adds, Lidarr requests, cancels)
now persists across network loss. Controllers write their optimistic
local state to drift first, then try the REST call; on failure
the call is enqueued in cached_mutations rather than rolled back.
MutationReplayer drains the queue on connectivity transitions and
a 1-minute periodic tick.

**Infrastructure (schema 8):**
* CachedMutations table — id / kind / payload (JSON) / createdAt
  / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a
  permanently-failing mutation eventually drops, and next sync
  reconciles drift to the server's authoritative state.
* MutationQueue.enqueue / pendingCount
* MutationReplayer.start + .drain — start fires from app.dart's
  postFrameCallback alongside SyncController / Prefetcher / etc.
* Kind registry: like.add / like.remove / quarantine.flag /
  quarantine.unflag / playlist.append / request.create /
  request.cancel — each with a Ref+payload handler that re-fires
  the corresponding REST call.

**Wired surfaces:**
* LikesController.toggle — optimistic drift like/unlike stays
  across REST failure; queues the call. Drops the old rollback.
* MyQuarantineController.flag / .unflag — same pattern. Hide/unhide
  visibly persists offline; replays when back online.
* addToPlaylistActionProvider — now does an optimistic
  cached_playlist_tracks write (position = max + 1) so the
  playlist detail screen shows the new track instantly. Queues
  appendTracks on REST failure.
* DiscoverScreen._request — queues request.create on DioException.
  No drift state for the request itself (myRequestsProvider is
  still REST-only) so the row won't show on /requests until replay
  succeeds — acceptable for v1.
* MyRequestsController.cancel — optimistic in-memory remove no
  longer restores on failure; queues request.cancel instead.

**Test update:**
quarantine_provider_test "flag rolls back on server failure"
renamed and rewritten to assert the new offline behavior:
optimistic drift row persists, mutation is enqueued for replay.

**Out of scope (v2):**
* Playlist create / rename / delete (no Flutter UI exposes these yet)
* Lidarr request optimistic local row (would need a cached_requests
  drift table)
* UI "syncing N pending changes" indicator (operator preference:
  silent unless we find a concrete need)
2026-05-14 18:27:05 -04:00
bvandeusen 60085b1368 feat(cache): CacheFiller background metadata sweeper
Periodic worker that walks cached_artists for missing album lists
and cached_albums for missing track lists, then fills them via
/api/artists/:id + /api/albums/:id. Newly-discovered album covers
are pre-warmed into flutter_cache_manager's disk cache too.

Solves the "tap an artist → empty album area → pop in" experience:
artistAlbumsProvider was drift-first but the cache only got
populated when the user navigated TO an artist. SyncController's
/api/library/sync delta doesn't carry per-artist album lists (those
are query-time derived). Now the filler pre-populates them in the
background so the drift hit is real on first tap.

Pacing (intentionally conservative):
* 10-second initial delay so SyncController has time to land its
  first sync — the WHERE NOT EXISTS query has nothing to do until
  cached_artists is populated.
* 5-minute interval thereafter. Once steady state is reached the
  sweep is a cheap drift query + early exit.
* 200ms throttle between per-entity REST requests — never competes
  with active playback.
* 200 entities per sweep cap so a fresh install with thousands of
  artists doesn't tie up the network for one continuous run. Next
  sweep picks up where this one left off (NOT EXISTS naturally
  skips already-filled rows).

Wall time estimate for a 1000-artist library: ~3-4 minutes spread
over multiple sweeps. Single round-trip per artist (new
getArtistDetail API method returns artist + albums in one shot).

Activated from app.dart's postFrameCallback alongside the existing
SyncController / Prefetcher / MetadataPrefetcher / LiveEvents
hooks. Disposed via ref.onDispose when the provider scope tears
down (effectively process death in practice).
2026-05-14 17:34:42 -04:00
bvandeusen fb95a462fb fix(player): align audio + UI on track change, prewarm covers + palette
Four related fixes to the player flow that together remove the
audio↔UI lag on track change:

1. **Prefetcher pre-warms covers + palette for the next N tracks.**
   The existing prefetcher pinned audio files only. When auto-advance
   landed on the next track, the cover bytes were cold → mediaItem
   broadcast with artUri=null → now-playing screen stalled in
   _scheduleSwap awaiting precacheImage of a file that didn't exist
   yet. Each upcoming queue item now also fires
   AlbumCoverCache.getOrFetch (writes bytes to disk so _toMediaItem's
   peekCached returns the path) and AlbumColorCache.getOrExtract
   (memoizes the dominant color). Both fire-and-forget; idempotent
   if already cached.

2. **AlbumColorCache.peekColor sync getter** so the now-playing
   fast-path can read the memoized color without awaiting a Future.

3. **_scheduleSwap fast path** when cover bytes + palette are
   already cached (the common in-queue auto-advance case): commit
   _displayedMedia / _displayedDominant synchronously in setState
   without awaiting. The async preload remains as the slow-path
   fallback for genuine cold cache. This is what closes the gap
   the user reported: "art is loading and metadata hasn't updated
   but the new song is playing."

4. **setQueueFromTracks: build source before broadcasting queue /
   mediaItem.** Previously we broadcast immediately for snappy UI;
   if _buildAudioSource threw, the UI showed the new track while
   the player held the old source. Now: build first, broadcast
   only on success. Source build is sub-100ms on warm cache so the
   tap response cost is imperceptible. _suppressIndexUpdates is set
   around setAudioSources + broadcast so a transient currentIndex
   emission can't cross-broadcast the OLD queue's entry at the NEW
   index.

5. **playbackEventStream error handler skips past failing tracks.**
   Previously errors only logged. The player would go silent on a
   404 / decoder failure but mediaItem stayed on the failed track —
   user saw "now playing X" with no audio. Now seekToNext on error;
   if at queue tail, pause cleanly so PlaybackState reflects idle.
2026-05-14 16:42:19 -04:00
bvandeusen 67bacac84b fix(test): capture cacheFirst stream Future before feeding controller 2026-05-14 15:59:16 -04:00
bvandeusen e59ccba961 revert(player): MediaSession surface back to 5 advertised actions
Pixel Watch 2 stopped showing controls entirely after v2026.05.13.3's
MediaSession expansion. Reverting the additive pieces:

* systemActions back to the original 5 (play / pause / skipPrev /
  skipNext / seek). stop, skipToQueueItem, setShuffleMode,
  setRepeatMode, setRating removed.
* controls list back to skipPrev / play|pause / skipNext (no stop).
* stop() override removed — let BaseAudioHandler default apply
  (probably needs to be a no-op for the MediaSession to stay alive
  through certain lifecycle events that audio_service triggers
  internally; the override was actually halting the session).
* MediaItem.rating no longer set in _toMediaItem. The Android
  MediaSession.setRating() path requires setRatingType(RATING_HEART)
  to actually expose to controllers, and audio_service doesn't
  surface that config knob — broadcasting an unanchored rating
  appears to make Wear OS reject the session entirely.

Kept in place:
* skipToQueueItem override — still needed for QueueScreen's direct
  handler call (not routed through MediaSession actions).
* setRating override + LikeBridge wiring — harmless if never
  invoked, and lights up automatically if we figure out how to
  configure the rating type later.
* AlbumCoverCache.peekCached for sync artUri seed — that part
  worked, and the failure mode would be a missing cover, not a
  rejected session.

Watch should come back to its previous "sometimes works" state from
v2026.05.13.2 (basic controls only). Getting past that needs proper
MediaSession config that audio_service either doesn't expose or
requires platform-channel work.
2026-05-14 15:44:14 -04:00
bvandeusen e38189470b test(cache_first): update test for new yield-after-fetch semantics
The cacheFirst fix in 5511f87 added a yield after fetchAndPopulate
so streams never hang when populate is a no-op for this filter
(the liked-tab spinner-forever bug). Test expectation updated: the
first emission after an empty drift is now the still-empty yield
("we tried, nothing to show yet"), and the simulated drift re-emit
yields the populated rows as the second emission.
2026-05-14 15:41:00 -04:00
bvandeusen 5511f87b4b fix(flutter): cacheFirst no longer hangs on no-match cold fetches
Liked tab loaded into an infinite spinner when the user had likes
in one category but not all three. Root cause: the three liked-tab
providers share one _populateLikeIds function. When the populate
writes track rows, drift watch fires for cached_likes (the table
all three providers watch). The track provider's stream re-emits
with rows.isNotEmpty → yields populated. The album and artist
streams re-emit with rows.isEmpty (user has no album/artist likes),
re-enter cacheFirst's rows-empty branch, fire populate AGAIN, drift
fires again, repeat — never yielding, .isLoading stays true forever,
UI spins.

Generalises beyond the liked case: any cacheFirst with a populate
that writes to a watched table but produces no rows matching this
filter would loop. Fix tracks coldFetchAttempted per subscription
so the first fetch is the only fetch via the rows-empty branch;
subsequent empty emissions yield empty. Also yields current rows
after a successful populate so a true no-op fetchAndPopulate (server
genuinely empty, fresh-install with no library data) doesn't hang
when drift doesn't re-emit for an empty batch.

For populated cases, the order is: spinner → brief empty yield from
the post-populate yield → drift watch re-emits with rows → populated.
UI flashes empty for one frame. Acceptable trade-off for the
no-spin guarantee.

Also matches the timeout pattern: liked providers' isOnline gains
the same 3-second timeout the home/library-list providers already
had, so a stuck connectivity check can't extend the hang.
2026-05-14 15:21:55 -04:00
bvandeusen e5ab471ce1 Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main 2026-05-14 18:19:39 +00:00
bvandeusen 28b0107925 chore(flutter): bump versionCode to 4 for v2026.05.13.3 hotfix 2026-05-14 14:19:11 -04:00
bvandeusen bfad4dddb6 fix(player): call Rating.hasHeart() as method, not getter 2026-05-14 14:07:02 -04:00
bvandeusen d1e276204e feat(player): expand MediaSession surface for Wear + lock-screen + Auto
External media controllers (Android Wear, Auto, Bluetooth dashes,
lock-screen widgets) consume the audio_service MediaSession and
silently no-op on any action that isn't in the handler's
systemActions set. Several handler methods were already implemented
but never advertised, plus stop() defaulted to a no-op — which
matched user reports of "media controller on the watch sometimes
works but doesn't play nice with Minstrel."

This patch lines the advertised surface up with what the handler
actually implements + wires a native heart-rating button.

**Expanded controls + systemActions:**
- Added MediaControl.stop to the expanded controls list.
- systemActions now also enumerates stop, skipToQueueItem (override
  shipped in v2026.05.13.1), setShuffleMode, setRepeatMode, and
  setRating. Without these in the set, Android 13+ drops the
  corresponding callbacks from external surfaces.

**stop() override:** halts _player and dismisses the foreground
notification via super.stop(). Default just flipped processingState
to idle without releasing the audio session — external surfaces
treated that as "paused forever".

**setRating wiring (native heart-button protocol):** new LikeBridge
adapter passes through configure() carrying toggleTrackLike +
isTrackLiked closures over LikesController and likedIdsProvider.
- setRating override flips the like through the bridge and re-emits
  mediaItem so the watch's heart icon updates immediately.
- _toMediaItem populates MediaItem.rating on every track change so
  the right filled/outlined heart shows on track-A → track-B.
- PlayerActions ref.listen on likedIdsProvider calls
  refreshCurrentRating so toggling a like from TrackRow / kebab /
  another device (SSE) also keeps the watch icon in sync.

**artUri seed on first broadcast:** AlbumCoverCache.peekCached
returns the file path synchronously when the cover is already on
disk. _toMediaItem uses this so warm-cache tracks broadcast with
artUri populated from the first frame — external controllers see
the cover immediately instead of waiting for the later async
_loadArtForCurrentItem path. Cold-cache tracks fall through to that
path unchanged.
2026-05-14 13:43:18 -04:00
bvandeusen 2df35e6227 fix(player): seed full-player display state from current mediaItem on mount
Regression from v2026.05.13.2's load-then-swap rewrite. _displayedMedia
only got populated by the ref.listen callback on mediaItem changes,
but ref.listen doesn't fire on initial subscription — it only fires
on transitions after the listener is registered. So opening the full
player while a track was already playing left _displayedMedia null
and the screen rendered "Nothing playing." even though the mini bar
showed a live track.

initState now reads the current mediaItem synchronously and seeds
_displayedMedia immediately (and _displayedDominant from the color
provider's cached value when available). A post-frame
_scheduleSwap(current) runs to ensure the cover bytes are decoded
and dominant color resolved when the user opens the player to a
track whose album hasn't yet been color-extracted in this session.
2026-05-14 13:16:20 -04:00
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 30fc7603d4 chore(flutter): bump versionCode to 3 for v2026.05.13.2 hotfix 2026-05-14 12:31:05 -04:00
bvandeusen 86d67f6fc6 fix(player): preload-then-swap cover transitions on both player surfaces
Previous fixes layered AnimatedSwitcher fades on top of a race: the
audio_handler broadcasts MediaItem twice on every track change
(bare metadata first, then with artUri once AlbumCoverCache resolves)
and the image bytes themselves decode asynchronously after the
widget mounts. The fades just smeared the resulting placeholder
flash without addressing the underlying ordering.

Rewrite the decision process around "load first, then swap":

**Mini player** (rapid change is acceptable per operator preference):
- Drop AnimatedSwitcher entirely
- PlayerBar becomes stateful, holds the most-recent non-null artUri
- Builds the child MediaItem with artUri = currentArtUri ?? _lastArtUri,
  so the previous cover stays visible across the null-artUri gap and
  the new cover snaps in the moment its artUri arrives

**Full player** (operator wants the image fully loaded before any
visible change):
- Introduce _displayedMedia + _displayedDominant state
- ref.listen on mediaItemProvider schedules a preload for each new
  track id (and for the artUri-bearing rebroadcast on the same id)
- _scheduleSwap awaits precacheImage on the file:// artUri AND
  awaits albumColorProvider's future for the dominant color
- Only then setState flips _displayedMedia + _displayedDominant in
  one frame — cover, title, gradient all advance atomically
- Drop the per-element AnimatedSwitcher wrappers; the backdrop
  AnimatedContainer still tweens between successive dominant
  colors so the gradient transition is smooth, not snap

Concurrency: rapid skips drop stale preload completions via
_pendingPreloadId. Decode/color failures fall through to the
previous dominant + the ServerImage/error-builder fallbacks.
2026-05-14 12:07:50 -04:00
bvandeusen 8cb9a8b797 fix(flutter): restore artist coverUrl on drift-cached ArtistRef
Multi-artist surfaces (home Rediscover, Library Artists tab, Liked
Artists carousel) were all rendering the music-notes placeholder
instead of real artist covers. Root cause:

CachedArtist.toRef() returned an ArtistRef with empty coverUrl —
the cache doesn't store the representative album id the server
derives at query time, and the adapter never reconstructed it.
The artist detail screen worked coincidentally because it derives
its header cover from `artist.albums[0].id` directly rather than
the ArtistRef's coverUrl field.

Fix:
* CachedArtistAdapter.toRef() now accepts an optional coverAlbumId
  and reconstructs `/api/albums/<id>/cover` when given. Matches the
  pattern AlbumRef uses (deterministic URL from entity id).
* artistTileProvider, libraryArtistsProvider, and artistProvider
  (single-artist) each LEFT JOIN cached_albums ordered by sort_title.
  First row per artist carries the alphabetically-first album id;
  toRef projects that into the cover URL.
* Multi-artist queries dedup in toResult since the join multiplies
  rows by album count.

Artists with no albums yet in drift come through with empty
coverUrl — UI falls back to the music-notes placeholder, same
behavior as before for that legitimately-coverless state.
2026-05-14 12:01:02 -04:00
bvandeusen 7339815ea9 Merge pull request 'release v2026.05.13.1: player + Discover hotfix' (#44) from dev into main 2026-05-14 15:17:45 +00:00
bvandeusen 2a18e91c39 feat(flutter): drift-first Library tabs + systemPlaylistsStatus + tab pre-warm
Four-part change to push more surfaces onto the drift cache and
eliminate cold-tab-visit latency on the Library screen.

* **Library Artists tab** — _libraryArtistsProvider migrates from
  REST-paginated AsyncNotifier with infinite-scroll loadMore to a
  drift-first StreamProvider over cached_artists ordered by
  sortName. Sync already populates the full set; cacheFirst's
  fetchAndPopulate covers the fresh-install + sync-not-yet-done
  cold case via /api/artists?limit=1000. SWR refresh on every
  visit. GridView.builder lazily realizes only visible cells so
  loading the full list up front is fine for typical libraries.
  loadMore + NotificationListener gone.

* **Library Albums tab** — same migration, drift-first over
  cached_albums joined with cached_artists for the artistName
  field.

* **systemPlaylistsStatusProvider** — new CachedSystemPlaylistsStatus
  single-row table (schema 6 → 7, JSON blob like CachedHomeSnapshot)
  for the home Playlists row's "building / pending / failed"
  placeholder logic. Drift-first means the row paints with the
  prior status instantly instead of flickering through
  SystemPlaylistsStatus.empty() while the REST call resolves.

* **Library screen tab pre-warm** — ref.listen on all 5 tab
  providers in _LibraryScreenState.build subscribes them upfront
  so swiping between tabs feels instant rather than each tab
  paying its own cold-cache cost on first visit. cacheFirst
  handles dedupe of concurrent fetchAndPopulate triggers.

Test mock updated for the StreamProvider type change on
systemPlaylistsStatusProvider.
2026-05-14 10:14:46 -04:00
bvandeusen 507c532f6d fix(flutter): drop redundant foundation import (debugPrint via material) 2026-05-14 08:42:50 -04:00
bvandeusen bf0ef5e0c3 fix(flutter): keep artist avatars round in the Library grid
ArtistCard hardcoded its avatar at Container(width: 124, height:
124) inside a ClipOval. In the Library Artists 3-column grid the
cell is narrower than the card's nominal 140dp width — on a typical
phone the cell is ~109dp, the padded inner content area ~93dp. The
parent constrained the Container's width to ~93dp but the explicit
height stayed 124dp, so ClipOval clipped a 93×124 rectangle and
the avatar rendered as a vertical ellipse.

Fix mirrors AlbumCard's pattern: ArtistCard takes an optional
`width` parameter (default 140 for horizontal carousels) and
derives coverSize = width - 16, so the Container is always square.
ArtistsTab now uses LayoutBuilder to compute cell width and passes
it through, same as AlbumsTab. Avatar stays a true circle at any
cell width.

mainAxisExtent on the grid replaces the previous fixed
childAspectRatio so cell height tracks cellW + name line, with
slack matching AlbumsTab's overflow guard.
2026-05-14 08:39:37 -04:00
bvandeusen 6efb3159d5 fix(flutter): silence 404 log noise from missing playlist covers
Playlist collages aren't generated until the build job runs over a
playlist with tracks — system playlists (For-You / Discover / Songs-
like) and any newly-created playlist hit a brief window where
/api/playlists/:id/cover returns 404. ServerImage's errorWidget
already renders the visual fallback (queue_music icon over slate);
this fix just keeps cached_network_image from spamming the dev
console with HttpExceptionWithStatus stack traces.

errorListener filters 404 specifically — auth (401/403) and any
5xx still log so real connectivity / permission issues stay visible.

User-visible behavior unchanged; this is a dev-mode log hygiene fix.
2026-05-14 08:32:35 -04:00
bvandeusen 8f1bc60757 chore(flutter): bump versionCode to 2 for v2026.05.13.1 hotfix 2026-05-14 08:30:23 -04:00
bvandeusen c29d25d1cb fix(playlists): dedup tracks across discover buckets
Discover playlists could surface the same track twice with the
duplicates landing back-to-back — a "first song plays, then plays
again, skip works" symptom user reported on v2026.05.13.0. Root
cause: interleaveBuckets rotates one track per pass per bucket but
never tracks which IDs it has already emitted, so a track that's
both a dormant-artist pick AND a random-unheard pick comes out
once from each bucket.

On a single-user server the crossUser bucket is empty, so the
redistribute step rolls its slots into dormant + random. Their
output then interleaves d0, r0, d1, r1, … — and when d0 == r0
(common: a dormant-artist track is also valid for random-unheard)
the result is [X, X, …] with adjacent duplicates.

Fix: track seen track IDs across all buckets while interleaving;
skip already-taken IDs and advance to the next index in that
bucket. Dedup priority is bucket order, so a track in both
dormant and random comes from dormant.

Regression test covers the single-user case directly. The existing
round-robin test still passes — no shared IDs in that fixture.

Note: stale duplicates already written to drift / served as cached
playlists will clear naturally on the next playlist rebuild (the
03:00-local refresh, or any manual /api/me/playlists/refresh).
2026-05-14 07:50:35 -04:00
bvandeusen 2ebe6229b7 fix(player): smooth full-player cover + backdrop on track change
Two related "snap in" effects on the now-playing screen:

1. **Album art snapped in after the fade.** AnimatedSwitcher cross-
   fades the new _AlbumArt over 300ms, but FileImage's bytes weren't
   decoded yet — the widget was visually empty during the fade and
   the cover landed abruptly after. precacheImage on the new file://
   artUri pre-decodes the bytes so by the time AnimatedSwitcher
   mounts the new tile, the cover paints synchronously inside it.
   The cross-fade now carries real content end-to-end.

2. **Backdrop color snapped in.** albumColorProvider.family is
   loading for the new id during the track-change moment, so
   dominant fell back to fs.obsidian; AnimatedContainer tweened to
   obsidian and then snapped to the resolved color a beat later.
   _NowPlayingScreenState now holds _lastDominant across builds:
   while extraction for the new id is loading, the gradient stays
   on the previous album's color, then animates straight to the
   new one once it resolves. No intermediate obsidian stop.

Net effect: track changes feel like a single smooth transition
instead of fade-out → blank → snap.
2026-05-14 07:41:27 -04:00
bvandeusen 6a08d94255 fix(player): smooth mini bar cover swap across track change
Audio handler broadcasts MediaItem twice on every track change:
once with artUri=null (the new track's bare metadata), then again
with artUri pointing at the AlbumCoverCache file once the cover
lands on disk. The mini player's cover element was rebuilding in
place: previous track's image → slate placeholder → new track's
image. That flash is the flicker reported on the v2026.05.13.0 build.

AnimatedSwitcher around the cover (180ms crossfade) keyed by the
artUri value makes the swap a smooth crossfade instead of a visible
snap. The Hero parent stays — its tag is stable across track changes
so the mini→full bar expansion animation keeps working unchanged.
2026-05-14 07:37:42 -04:00
bvandeusen 3e7b2582a2 fix(player): queue skip + cut over silence on track tap
Two bugs in the audio handler caused the playback issues seen on the
v2026.05.13.0 build:

1. **Queue button on the now-playing screen did nothing.** MinstrelAudio
   Handler never overrode skipToQueueItem, so taps in QueueScreen fell
   through to BaseAudioHandler's empty default. The queue UI updated
   nothing because the handler did nothing. Now overridden: rebuilds
   the source list via setQueueFromTracks(_lastTracks, initialIndex)
   so the targeted item plays cleanly even when its source hadn't been
   built yet by the background fill.

2. **Tapping a song in a playlist let the previous track bleed through
   until the new source finished building.** setQueueFromTracks awaits
   _buildAudioSource before swapping the player's source list, and
   that wait can be a few hundred ms on a cache miss. During the wait
   the old source kept playing while the mini player UI had already
   flipped to the new title/artist. Now pause()ing the player at the
   start of setQueueFromTracks silences the old source the moment the
   user taps.

Also stashes the most recent TrackRef list as _lastTracks so
skipToQueueItem can reconstruct sources without having to peek into
just_audio's internal source list.
2026-05-14 07:36:59 -04:00
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
250 changed files with 13137 additions and 2355 deletions
+12 -6
View File
@@ -1,9 +1,13 @@
name: flutter
# Analyze + test + build the Flutter mobile client. Only runs when the
# diff touches flutter_client/ or one of the shared web inputs the
# sync_shared.sh script copies. Other pushes skip — this workflow is
# independent from the Go/web test.yml and release.yml.
# Analyze + test the Flutter mobile client; build an APK only where
# it's actually consumed. dev pushes: analyze+test+codegen only
# (~3min) — the operator dev-tests via a local Android Studio build,
# not the CI artifact. main pushes: also build a debug APK as the
# native-build safety net (Gradle/manifest/plugin breakage that
# analyze+test can't catch) before any release tag. tags: signed
# release APK. Only runs when the diff touches flutter_client/ or a
# shared web input sync_shared.sh copies.
on:
push:
@@ -69,7 +73,9 @@ jobs:
run: flutter test --reporter compact
- name: Build debug APK
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
# main-only: native-build safety net before release tags.
# dev skips this (~7min) — operator dev-tests locally.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: flutter build apk --debug
- name: Decode signing keystore
@@ -112,7 +118,7 @@ jobs:
flutter build apk --release --build-name="${TAG}"
- name: Upload debug APK artifact
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: forgejo/upload-artifact@v3
with:
name: minstrel-debug-${{ github.sha }}
+70 -2
View File
@@ -4,8 +4,16 @@ name: test-go
# dev/main and PRs to main, scoped to Go-side files only — web-only or
# Flutter-only diffs don't trigger this workflow.
#
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
# they should guard with testing.Short() so this short-mode run skips them.
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
# `integration` (full `go test -race` against an ephemeral Postgres).
#
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
# the runner's Docker daemon also runs the operator's dev compose stack,
# so service containers get NO published ports (collision) and no
# service-name DNS. We discover the service container by the job-scoped
# name filter via the mounted docker socket and reach it by bridge IP.
# The exactly-one assertion is a hard guard — pointing tests at the dev
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without needing the SPA to be freshly built. Real builds happen in
@@ -54,3 +62,63 @@ jobs:
- name: go test (short, race)
run: go test -short -race ./...
integration:
runs-on: go-ci
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the operator's dev compose
# Docker daemon; publishing a fixed host port collides.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration suite (discover service by bridge IP, migrate, test)
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. act_runner attaches the job
# container and its service container(s) to a shared per-job
# network, so scope discovery to a postgres that sits on a
# network THIS job container is also on. The old
# `--filter name=integration` matched EVERY concurrent
# integration run's postgres (a dev push + the main-merge run
# overlap → 2 candidates → false "expected exactly 1" abort).
# The operator's dev compose `minstrel-postgres-*` is never on
# this job's network; skip it explicitly as belt-and-suspenders
# (a wrong target would truncate real data).
SELF=$(cat /etc/hostname)
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
test -n "$SELF_NETS"
echo "self ($SELF) networks: $SELF_NETS"
PG_ID=""
PG_NAME=""
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
done
done
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
echo "selected postgres: $PG_ID $PG_NAME"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race ./...
+11 -1
View File
@@ -1,4 +1,4 @@
.PHONY: generate test test-short lint build
.PHONY: generate test test-short test-integration lint build
SQLC_VERSION := 1.31.1
@@ -11,6 +11,16 @@ test:
test-short:
go test -short -race ./...
# Full suite incl. integration tests, against the dedicated minstrel_test
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
# the test DB exists (idempotent — createdb errors if present, ignored).
test-integration:
docker compose up -d postgres
-docker compose exec -T postgres createdb -U minstrel minstrel_test
# -p 1: integration packages share one test DB and each TRUNCATEs it;
# concurrent package binaries deadlock on TRUNCATE. Serialize packages.
MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -p 1 -race ./...
lint:
golangci-lint run ./...
+10
View File
@@ -83,6 +83,16 @@ Two concurrent dev processes:
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
### Testing
- Unit + race (no DB): `make test-short`.
- Full suite incl. integration tests: `make test-integration`. This runs
against a dedicated `minstrel_test` database so a test run never
truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing.
- CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
### Production build
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// runMigrate applies the embedded migrations and exits. The server also
// auto-migrates on startup (main.go); this standalone path exists for CI
// (provision a fresh DB before the integration suite) and operators who
// want to migrate without starting the server.
func runMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
if err != nil {
return fmt.Errorf("init logger: %w", err)
}
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
return fmt.Errorf("migrate: %w", err)
}
fmt.Println("minstrel: migrations applied")
return nil
}
// runAdmin dispatches `minstrel admin <subcommand>`.
func runAdmin(args []string) error {
if len(args) == 0 {
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
}
switch args[0] {
case "reset-password":
return adminResetPassword(args[1:])
default:
return fmt.Errorf("unknown admin subcommand %q", args[0])
}
}
// adminResetPassword resets a user's credentials. It updates BOTH
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
// (plaintext, required for Subsonic t+s token verification) so neither
// auth path is left stale. Recovers a locked-out operator when the
// bootstrap password was missed or the DB volume was recreated (Fable
// #321) without DB surgery.
func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
username := fs.String("user", "admin", "username to reset")
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Database.URL == "" {
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
}
ctx := context.Background()
pool, err := db.Open(ctx, cfg.Database.URL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer pool.Close()
q := dbq.New(pool)
user, err := q.GetUserByUsername(ctx, *username)
if err != nil {
return fmt.Errorf("look up user %q: %w", *username, err)
}
pw := *password
generated := false
if pw == "" {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("generate password: %w", err)
}
pw = base64.RawURLEncoding.EncodeToString(b)
generated = true
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
ID: user.ID,
PasswordHash: string(hash),
}); err != nil {
return fmt.Errorf("update password_hash: %w", err)
}
sp := pw
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: &sp,
}); err != nil {
return fmt.Errorf("update subsonic_password: %w", err)
}
if generated {
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
} else {
fmt.Printf("minstrel: password for %q reset.\n", *username)
}
return nil
}
+69 -9
View File
@@ -14,7 +14,9 @@ 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/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
@@ -24,9 +26,26 @@ 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() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "admin":
if err := runAdmin(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err)
os.Exit(1)
}
return
case "migrate":
if err := runMigrate(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err)
os.Exit(1)
}
return
}
}
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
os.Exit(1)
@@ -103,8 +122,8 @@ func run() error {
logger.With("component", "scan_run"),
library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
},
); err != nil {
@@ -120,8 +139,8 @@ func run() error {
logger.With("component", "scan_run"),
library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
},
); err != nil {
@@ -146,9 +165,49 @@ 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)
// Per-tick Lidarr client factory: re-reads config so an admin save
// takes effect without a restart, returning nil while Lidarr is
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
// reconciler uses it to (re)send unconfirmed adds.
lidarrClientFn := func() *lidarr.Client {
c, cerr := lidarrCfg.Get(ctx)
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, 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)
@@ -163,8 +222,8 @@ func run() error {
scanCfg := library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
}
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
@@ -172,8 +231,9 @@ func run() error {
scheduler.Start(ctx)
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)
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
httpServer := &http.Server{
Addr: cfg.Server.Address,
Handler: srv.Router(),
+6
View File
@@ -0,0 +1,6 @@
-- Runs once, only on a fresh Postgres data volume (Docker entrypoint
-- initdb hook). Creates the dedicated integration-test database so
-- `go test` never truncates the operator's dev `minstrel` DB (Fable
-- #339). For an already-initialised volume this script does NOT run;
-- `make test-integration` creates the DB idempotently instead.
CREATE DATABASE minstrel_test OWNER minstrel;
+11 -4
View File
@@ -1,10 +1,14 @@
version: "3.9"
# Local development environment for Minstrel.
# Not used by CI — integration tests run against this compose stack manually:
# docker compose up -d postgres
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \
# go test ./...
#
# Integration tests run against a SEPARATE database (minstrel_test) so a
# test run never truncates the dev `minstrel` DB (Fable #339). Use the
# Makefile target, which ensures the test DB exists then points the
# tests at it:
# make test-integration
# (CI runs the same suite against its own ephemeral Postgres service —
# see .forgejo/workflows/test-go.yml.)
#
# Full stack (server + db):
# docker compose up --build
@@ -22,6 +26,9 @@ services:
# - "5432:5432"
volumes:
- minstrel-pgdata:/var/lib/postgresql/data
# Creates minstrel_test on a fresh volume (Fable #339). No-op on
# an existing volume — make test-integration ensures it instead.
- ./deploy/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
interval: 5s
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- Lucide "heart" (filled) — same path verbatim from lucide-icons/lucide -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<!-- Lucide "heart" (outline) — path verbatim from lucide-icons/lucide -->
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
</vector>
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -34,7 +35,7 @@ class AdminLandingScreen extends ConsumerWidget {
children: [
AdminSectionCard(
key: const Key('admin_card_requests'),
icon: Icons.inbox,
icon: LucideIcons.inbox,
title: 'Requests',
subtitle: 'Approve or reject Lidarr requests',
count: c.requests,
@@ -42,7 +43,7 @@ class AdminLandingScreen extends ConsumerWidget {
),
AdminSectionCard(
key: const Key('admin_card_quarantine'),
icon: Icons.warning_amber,
icon: LucideIcons.triangle_alert,
title: 'Quarantine',
subtitle: 'Resolve scan failures',
count: c.quarantine,
@@ -50,7 +51,7 @@ class AdminLandingScreen extends ConsumerWidget {
),
AdminSectionCard(
key: const Key('admin_card_users'),
icon: Icons.people,
icon: LucideIcons.users,
title: 'Users',
subtitle: 'Manage accounts and invites',
count: c.users,
@@ -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
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -59,7 +60,7 @@ class AdminUsersScreen extends ConsumerWidget {
trailing: TextButton.icon(
key: const Key('invite_generate_button'),
onPressed: () => _showGenerateInvite(context, ref),
icon: Icon(Icons.add, color: fs.parchment),
icon: Icon(LucideIcons.plus, color: fs.parchment),
label: Text('Generate',
style: TextStyle(color: fs.parchment)),
),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../models/admin_quarantine_item.dart';
import '../../theme/theme_extension.dart';
@@ -45,7 +46,7 @@ class AdminQuarantineRow extends StatelessWidget {
),
trailing: PopupMenuButton<String>(
key: Key('admin_quarantine_menu_${item.trackId}'),
icon: Icon(Icons.more_vert, color: fs.parchment),
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment),
onSelected: (action) async {
switch (action) {
case 'resolve':
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../models/admin_user.dart';
import '../../theme/theme_extension.dart';
@@ -29,7 +30,7 @@ class AdminUserRow extends StatelessWidget {
_Badge(label: 'auto-approve', color: fs.moss),
],
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
onTap: onTap,
);
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter/services.dart';
import '../../models/invite.dart';
@@ -31,7 +32,7 @@ class InviteRow extends StatelessWidget {
),
),
IconButton(
icon: Icon(Icons.copy, color: fs.ash, size: 18),
icon: Icon(LucideIcons.copy, color: fs.ash, size: 18),
tooltip: 'Copy',
onPressed: () =>
Clipboard.setData(ClipboardData(text: invite.token)),
@@ -63,7 +64,7 @@ class InviteRow extends StatelessWidget {
],
),
trailing: IconButton(
icon: Icon(Icons.delete_outline, color: fs.oxblood),
icon: Icon(LucideIcons.trash_2, color: fs.oxblood),
tooltip: 'Revoke',
onPressed: onRevoke,
),
@@ -1,11 +1,25 @@
import 'package:dio/dio.dart';
import '../../models/artist_suggestion.dart';
import '../../models/lidarr.dart';
class DiscoverApi {
DiscoverApi(this._dio);
final Dio _dio;
/// GET /api/discover/suggestions — out-of-library artist suggestions
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
/// may be empty). The server already filters in-library and
/// non-terminal-request candidates.
Future<List<ArtistSuggestion>> listSuggestions() async {
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
final raw = r.data ?? const [];
return raw
.map((e) =>
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
/// 60s LRU cache for repeat queries so re-typing the same string in
/// quick succession is cheap.
@@ -0,0 +1,91 @@
import 'package:dio/dio.dart';
/// Thin client for POST /api/events — the play-event lifecycle the
/// server uses for history, recommendation scoring, ListenBrainz
/// scrobbles, and (since #415) system-playlist rotation.
///
/// Mirrors the web events dispatcher's three calls. Best-effort by
/// contract: callers swallow errors — a missed event is acceptable
/// per the server spec's v1 stance, and the server's
/// auto-close-prior-open keeps history sane even if an ended/skipped
/// is lost.
class EventsApi {
EventsApi(this._dio);
final Dio _dio;
/// POST play_started. Returns the server's play_event_id (used to
/// close the row later), or null if the call failed / response was
/// malformed. `source` tags the originating system playlist
/// ('for_you' | 'discover') so the server advances that rotation;
/// omit for library / user-playlist / radio plays.
Future<String?> playStarted({
required String trackId,
required String clientId,
String? source,
}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/events',
data: {
'type': 'play_started',
'track_id': trackId,
'client_id': clientId,
if (source != null && source.isNotEmpty) 'source': source,
},
);
return (r.data ?? const {})['play_event_id'] as String?;
}
Future<void> playEnded({
required String playEventId,
required int durationPlayedMs,
}) async {
await _dio.post<void>(
'/api/events',
data: {
'type': 'play_ended',
'play_event_id': playEventId,
'duration_played_ms': durationPlayedMs,
},
);
}
Future<void> playSkipped({
required String playEventId,
required int positionMs,
}) async {
await _dio.post<void>(
'/api/events',
data: {
'type': 'play_skipped',
'play_event_id': playEventId,
'position_ms': positionMs,
},
);
}
/// Replays a complete play that happened offline / on a flaky
/// connection (#426 part B). One call: the server records start+end
/// from `atIso` (the original play-start time) + durationPlayedMs,
/// applies the canonical skip rule, and advances #415 rotation when
/// source is a system playlist. Driven by the offline mutation
/// queue, never the live path.
Future<void> playOffline({
required String trackId,
required String clientId,
required String atIso,
required int durationPlayedMs,
String? source,
}) async {
await _dio.post<void>(
'/api/events',
data: {
'type': 'play_offline',
'track_id': trackId,
'client_id': clientId,
'at': atIso,
'duration_played_ms': durationPlayedMs,
if (source != null && source.isNotEmpty) 'source': source,
},
);
}
}
@@ -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.
@@ -35,6 +54,24 @@ class LibraryApi {
return ArtistRef.fromJson(r.data ?? const {});
}
/// GET /api/artists/{id} — full response with the ArtistRef AND the
/// embedded album list, both parsed. Single round-trip variant used
/// by CacheFiller and other callers that want to populate both
/// cached_artists and cached_albums in one shot. The existing
/// getArtist / getArtistAlbums keep working for callers that only
/// need one half — they hit the same URL but the per-id Riverpod
/// caching layer dedupes.
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final body = r.data ?? const <String, dynamic>{};
final artist = ArtistRef.fromJson(body);
final albums = ((body['albums'] as List?) ?? const [])
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
return (artist: artist, albums: albums);
}
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
/// that need both the artist and its albums should issue two provider
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
@@ -48,6 +85,19 @@ class LibraryApi {
.toList(growable: false);
}
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
/// tracks — the online source for "Shuffle all". Bare JSON array.
Future<List<TrackRef>> shuffle({int limit = 100}) async {
final r = await _dio.get<List<dynamic>>(
'/api/library/shuffle',
queryParameters: {'limit': limit},
);
final raw = r.data ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
/// type the response as `List<dynamic>` rather than a Map envelope.
Future<List<TrackRef>> getArtistTracks(String id) async {
+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});
}
}
@@ -45,6 +45,17 @@ class PlaylistsApi {
return PlaylistDetail.fromJson(r.data ?? const {});
}
/// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2).
/// Same shape as get() but tracks are server-ordered rotation-aware
/// (unplayed-this-rotation first; resets when exhausted). {kind} is
/// the raw system_variant. Intentionally uncached — varies per play.
Future<PlaylistDetail> systemShuffle(String variant) async {
final r = await _dio.get<Map<String, dynamic>>(
'/api/playlists/system/$variant/shuffle',
);
return PlaylistDetail.fromJson(r.data ?? const {});
}
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
/// playlist detail with the new rows.
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
@@ -53,4 +64,17 @@ class PlaylistsApi {
data: {'track_ids': trackIds},
);
}
/// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds
/// the caller's system playlists and returns the named kind's new
/// playlist id, or null when the library is empty. {kind} is the
/// raw system_variant — the server routes generically off the
/// kind registry, no hyphen mapping.
Future<String?> refreshSystem(String variant) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/playlists/system/$variant/refresh',
data: const <String, dynamic>{},
);
return (r.data ?? const {})['playlist_id'] as String?;
}
}
+56
View File
@@ -1,9 +1,19 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'cache/cache_filler.dart';
import 'cache/metadata_prefetcher.dart';
import 'cache/offline_provider.dart';
import 'cache/mutation_queue.dart';
import 'cache/prefetcher.dart';
import 'cache/resume_controller.dart';
import 'cache/sync_controller.dart';
import 'player/play_events_reporter.dart';
import 'player/playback_error_reporter.dart';
import 'shared/live_events_dispatcher.dart';
import 'shared/routing.dart';
import 'theme/theme_data.dart';
import 'theme/theme_mode_provider.dart';
@@ -33,6 +43,51 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
// 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);
// Cache filler: background sweeper that walks cached_artists
// / cached_albums for missing relations and fetches the
// per-entity detail so tapping an artist surfaces albums
// immediately (drift hit, no /api/artists/:id round-trip at
// tap time). First sweep 10s after launch; every 5 minutes
// thereafter. Throttled 200ms between requests so it never
// competes with user activity.
ref.read(cacheFillerProvider);
// Mutation replayer: drains the cached_mutations queue when
// connectivity comes back. Controllers (LikesController,
// MyQuarantineController, the add-to-playlist + request flows)
// enqueue on REST failure so user intent persists across
// network loss instead of getting rolled back.
ref.read(mutationReplayerProvider);
// Play-events reporter (#415): the Flutter client otherwise
// reports no plays at all — this feeds history, recommendation
// scoring, scrobbles, and system-playlist rotation, and is the
// path that carries the `source` tag for #415.
ref.read(playEventsReporterProvider);
// Resume-on-launch (#54): restores the last persisted session
// (paused) and persists queue/index/position on track change,
// pause, and app teardown. Pairs with the #52 idle teardown so a
// torn-down session is recoverable instead of lost.
ref.read(resumeControllerProvider);
// Playback-error reporter (#58): turns the handler's silent
// dead-track skips into a debounced/coalesced SnackBar so a
// vanished track isn't mysterious (and aids server/cache debug).
ref.read(playbackErrorReporterProvider);
// Offline marker (#427 S1): periodic /healthz reachability
// probe → offlineProvider. Read here to start the poller; S4
// gates system-playlist play + Shuffle-all on it.
ref.read(offlineProvider);
// POST_NOTIFICATIONS (Android 13+) is denied-by-default until
// requested; without it the media notification is silently
// suppressed on physical devices. One-shot, post-first-frame so
// it never blocks launch; no-op on <13 / once already decided.
if (Platform.isAndroid) {
// ignore: unawaited_futures
Permission.notification.request();
}
});
}
@@ -42,6 +97,7 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
return MaterialApp.router(
title: 'Minstrel',
scaffoldMessengerKey: scaffoldMessengerKey,
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: mode.materialMode,
+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 =
+12 -1
View File
@@ -17,10 +17,21 @@ import '../models/track.dart';
import 'db.dart';
extension CachedArtistAdapter on CachedArtist {
ArtistRef toRef() => ArtistRef(
/// `coverAlbumId` lets the caller pass a representative album id so
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
/// URL. cached_artists doesn't store this directly — the server
/// derives it from the most-recent album at query time — so drift
/// readers that want the cover URL join cached_albums and pass the
/// first album's id through. Empty string yields empty coverUrl,
/// matching the server's behavior for endpoints that don't carry
/// the lookup (artist detail, search, raw cached_artists row reads).
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
id: id,
name: name,
sortName: sortName,
coverUrl: coverAlbumId.isNotEmpty
? '/api/albums/$coverAlbumId/cover'
: '',
);
}
+155 -58
View File
@@ -8,15 +8,20 @@ import 'package:path_provider/path_provider.dart';
import '../library/library_providers.dart' show dioProvider;
import 'db.dart';
/// Per-bucket cache usage (#427 S2). Rolling includes orphan files —
/// partials written by LockCachingAudioSource that were never indexed
/// (skip-before-fully-buffered) — so the rolling cap actually bounds
/// disk.
typedef BucketUsage = ({int liked, int rolling});
/// Owns the audio cache directory + drift index.
/// API:
/// - isCached(trackId)
/// - pathFor(trackId)
/// - pin(trackId, source) — downloads + indexes
/// - unpin(trackId)
/// - evict(targetBytes) — tiered LRU
/// - usageBytes()
/// - clearAll()
///
/// #427 S2: two storage buckets keyed by liked-ness, not by
/// CacheSource. A cached track currently in the user's liked set is
/// charged to (and evicted under) the Liked budget; everything else
/// is Rolling. This dedup is storage/eviction-only — it never filters
/// what the offline surfaces can play (S4). `CacheSource` is retained
/// on the API for callers but is now informational.
class AudioCacheManager {
AudioCacheManager({
required AppDb db,
@@ -37,7 +42,6 @@ class AudioCacheManager {
return d.path;
}
/// True if the trackId has a complete file on disk.
Future<bool> isCached(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
@@ -46,7 +50,6 @@ class AudioCacheManager {
return File(row.path).existsSync();
}
/// Returns the local file path if cached, else null.
Future<String?> pathFor(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
@@ -55,12 +58,14 @@ class AudioCacheManager {
return File(row.path).existsSync() ? row.path : null;
}
/// Downloads the track's stream to disk and indexes it. Idempotent
/// returns the existing path if already cached.
/// Downloads the track's stream to disk and indexes it. Idempotent.
/// lastPlayedAt is set now — pinning is play-intent.
Future<String?> pin(String trackId, {required CacheSource source}) async {
final existing = await pathFor(trackId);
if (existing != null) return existing;
if (existing != null) {
await touch(trackId);
return existing;
}
final dir = await _tracksDir();
final path = '$dir/$trackId.mp3';
final dio = await _dioFactory();
@@ -78,16 +83,15 @@ class AudioCacheManager {
path: path,
sizeBytes: size,
source: source,
lastPlayedAt: Value(DateTime.now()),
),
);
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.
/// Registers a file LockCachingAudioSource wrote itself. Called
/// once a track is fully buffered; it's being played, so stamp
/// lastPlayedAt.
Future<void> registerStreamCache(
String trackId,
String path,
@@ -100,11 +104,20 @@ class AudioCacheManager {
path: path,
sizeBytes: sizeBytes,
source: source,
lastPlayedAt: Value(DateTime.now()),
),
);
}
/// Removes a track from the index AND deletes the file.
/// Bumps lastPlayedAt for an already-indexed track so the rolling
/// LRU + the offline "Recently played" view reflect real plays
/// (not just download time). No-op if not indexed.
Future<void> touch(String trackId) async {
await (_db.update(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
}
Future<void> unpin(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
@@ -117,66 +130,150 @@ class AudioCacheManager {
.go();
}
/// 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.
/// Total bytes on disk (directory walk — authoritative; catches
/// orphan partials the index misses).
Future<int> usageBytes() async {
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) {
await for (final e in dir.list(followLinks: false)) {
if (e is File) {
try {
total += await entity.length();
} catch (_) {
// Race against concurrent writes / deletes — just skip.
}
total += await e.length();
} catch (_) {}
}
}
return total;
}
/// Evicts files until usage ≤ targetBytes. Eviction order:
/// incidental → autoPrefetch → autoPlaylist → autoLiked.
/// `manual` never evicts via this path; only clearAll() removes them.
Future<void> evict({required int targetBytes}) async {
final used = await usageBytes();
if (used <= targetBytes) return;
final order = [
CacheSource.incidental,
CacheSource.autoPrefetch,
CacheSource.autoPlaylist,
CacheSource.autoLiked,
];
int remaining = used - targetBytes;
for (final src in order) {
if (remaining <= 0) break;
final rows = await (_db.select(_db.audioCacheIndex)
..where((t) => t.source.equalsValue(src))
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
/// Bytes per bucket. Indexed rows split by liked-ness; on-disk
/// files with no index row (orphan partials) count as Rolling so
/// the rolling cap genuinely bounds disk.
Future<BucketUsage> bucketUsage(Set<String> liked) async {
final rows = await _db.select(_db.audioCacheIndex).get();
final indexed = <String>{};
var likedB = 0;
var rollingB = 0;
for (final r in rows) {
indexed.add(r.trackId);
if (liked.contains(r.trackId)) {
likedB += r.sizeBytes;
} else {
rollingB += r.sizeBytes;
}
}
final dir = Directory(await _tracksDir());
if (await dir.exists()) {
await for (final e in dir.list(followLinks: false)) {
if (e is! File) continue;
final name = e.uri.pathSegments.last;
final id = name.endsWith('.mp3')
? name.substring(0, name.length - 4)
: name;
if (indexed.contains(id)) continue;
try {
rollingB += await e.length();
} catch (_) {}
}
}
return (liked: likedB, rolling: rollingB);
}
/// Enforces both budgets independently (0 = unlimited).
///
/// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt
/// /cachedAt first), then sweep orphan files oldest-by-mtime, until
/// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU
/// until ≤ likedCap. Liked is only ever touched by its own (large)
/// cap, so normal use never evicts the user's liked library.
Future<void> evictBuckets({
required int likedCap,
required int rollingCap,
required Set<String> liked,
}) async {
final usage = await bucketUsage(liked);
if (rollingCap > 0 && usage.rolling > rollingCap) {
var over = usage.rolling - rollingCap;
final rolling = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.isNotIn(liked.toList()))
..orderBy([
(t) => OrderingTerm.asc(t.lastPlayedAt),
(t) => OrderingTerm.asc(t.cachedAt),
]))
.get();
for (final row in rows) {
if (remaining <= 0) break;
final f = File(row.path);
for (final r in rolling) {
if (over <= 0) break;
final f = File(r.path);
if (f.existsSync()) await f.delete();
await (_db.delete(_db.audioCacheIndex)
..where((t) => t.trackId.equals(row.trackId)))
..where((t) => t.trackId.equals(r.trackId)))
.go();
remaining -= row.sizeBytes;
over -= r.sizeBytes;
}
if (over > 0) await _sweepOrphans(over);
}
if (likedCap > 0 && usage.liked > likedCap) {
var over = usage.liked - likedCap;
final likedRows = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.isIn(liked.toList()))
..orderBy([
(t) => OrderingTerm.asc(t.lastPlayedAt),
(t) => OrderingTerm.asc(t.cachedAt),
]))
.get();
for (final r in likedRows) {
if (over <= 0) break;
final f = File(r.path);
if (f.existsSync()) await f.delete();
await (_db.delete(_db.audioCacheIndex)
..where((t) => t.trackId.equals(r.trackId)))
.go();
over -= r.sizeBytes;
}
}
}
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
/// button — `manual` source rows are removed here but only here.
/// Deletes orphan files (on disk, no index row) oldest-mtime-first
/// until `over` bytes are reclaimed. These are unindexed partials,
/// always Rolling, always evict-first.
Future<void> _sweepOrphans(int over) async {
final indexed = {
for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId
};
final dir = Directory(await _tracksDir());
if (!await dir.exists()) return;
final orphans = <({File f, int size, DateTime mtime})>[];
await for (final e in dir.list(followLinks: false)) {
if (e is! File) continue;
final name = e.uri.pathSegments.last;
final id =
name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name;
if (indexed.contains(id)) continue;
try {
final st = e.statSync();
orphans.add((f: e, size: st.size, mtime: st.modified));
} catch (_) {}
}
orphans.sort((a, b) => a.mtime.compareTo(b.mtime));
var remaining = over;
for (final o in orphans) {
if (remaining <= 0) break;
try {
await o.f.delete();
remaining -= o.size;
} catch (_) {}
}
}
/// Clears EVERY row + EVERY file. Wired to "Clear cache".
Future<void> clearAll() async {
final dir = await _tracksDir();
final d = Directory(dir);
if (d.existsSync()) {
for (final entity in d.listSync()) {
if (entity is File) await entity.delete();
for (final e in d.listSync()) {
if (e is File) await e.delete();
}
}
await _db.delete(_db.audioCacheIndex).go();
+235
View File
@@ -0,0 +1,235 @@
// Background metadata sweeper that walks the drift cache for missing
// relations and fills them via /api/artists/:id + /api/albums/:id.
// Cover bytes for newly-filled albums are pre-warmed too so the
// per-tile display path doesn't have to wait on network.
//
// Why this layer on top of SyncController and HydrationQueue:
// - SyncController.sync only ingests entities the server emitted
// into the library_changes delta for this user. For a fresh
// install, that's everything; for an existing user, only recent
// changes. The per-artist album list and per-album track list
// are NOT in those deltas — they're derived at query time.
// - HydrationQueue fills these lazily on tile render. CacheFiller
// fills them proactively on a slow schedule so tapping an artist
// surfaces albums immediately instead of triggering a fresh
// /api/artists/:id at tap time.
//
// Pacing is conservative — 200ms between requests, max 200 entities
// per sweep, 5-minute interval. Designed to never compete with user
// activity. Wall time on a 1000-artist library: ~3-4 minutes spread
// over multiple sweeps.
import 'dart:async';
import 'package:drift/drift.dart' show Variable;
import 'package:flutter/foundation.dart' show debugPrint;
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 '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/connectivity_provider.dart';
import '../library/library_providers.dart' show libraryApiProvider;
class CacheFiller {
CacheFiller(this._ref);
final Ref _ref;
Timer? _initialTimer;
Timer? _intervalTimer;
bool _running = false;
bool _disposed = false;
/// Delay between launch and the first sweep. Long enough for
/// SyncController to land its initial /api/library/sync so the
/// CacheFiller's "unfilled relations" query has meaningful work.
static const _initialDelay = Duration(seconds: 10);
/// Cadence between sweeps. Once a sweep finds nothing to do (steady
/// state) the interval becomes a cheap drift query + early exit.
static const _interval = Duration(minutes: 5);
/// Throttle between per-entity REST requests so the filler doesn't
/// saturate the server or compete with user-initiated playback.
static const _requestThrottle = Duration(milliseconds: 200);
/// Per-sweep cap. Without this, a fresh install with thousands of
/// artists would tie up the network for many minutes in one go.
/// The next sweep continues where this one left off (the WHERE
/// NOT EXISTS query naturally skips already-filled rows).
static const _maxIdsPerSweep = 200;
void start() {
_initialTimer = Timer(_initialDelay, _sweep);
_intervalTimer = Timer.periodic(_interval, (_) => _sweep());
}
Future<void> _sweep() async {
if (_disposed || _running) return;
final online = await _ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
if (!online) return;
_running = true;
try {
await _fillArtists();
if (_disposed) return;
await _fillAlbums();
} catch (e, st) {
debugPrint('cache_filler: sweep failed: $e\n$st');
} finally {
_running = false;
}
}
/// Find artists with no albums in cache and fetch /api/artists/:id
/// for each (single round-trip yields artist + albums via the new
/// getArtistDetail API method). Newly-discovered album covers
/// land in flutter_cache_manager's disk cache too so the next
/// visit to the artist's albums grid paints from disk.
Future<void> _fillArtists() async {
final db = _ref.read(appDbProvider);
final rows = await db.customSelect(
'''
SELECT a.id FROM cached_artists a
WHERE NOT EXISTS (
SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id
)
LIMIT ?
''',
variables: [Variable.withInt(_maxIdsPerSweep)],
).get();
final ids = rows.map((r) => r.read<String>('id')).toList();
if (ids.isEmpty) return;
final api = await _ref.read(libraryApiProvider.future);
final newAlbumIds = <String>[];
for (final id in ids) {
if (_disposed) return;
try {
final detail = await api.getArtistDetail(id);
await db.transaction(() async {
await db
.into(db.cachedArtists)
.insertOnConflictUpdate(detail.artist.toDrift());
if (detail.albums.isNotEmpty) {
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
detail.albums.map((a) => a.toDrift()).toList(),
);
});
newAlbumIds.addAll(detail.albums.map((a) => a.id));
}
});
} catch (e) {
debugPrint('cache_filler: fillArtist($id) failed: $e');
}
await Future.delayed(_requestThrottle);
}
if (newAlbumIds.isNotEmpty) {
await _prewarmCovers(newAlbumIds);
}
}
/// Find albums with no tracks in cache and fetch /api/albums/:id
/// for each. The bulk response carries the album's track list which
/// we persist into cached_tracks for instant album detail render
/// on the next visit.
Future<void> _fillAlbums() async {
final db = _ref.read(appDbProvider);
final rows = await db.customSelect(
'''
SELECT a.id FROM cached_albums a
WHERE NOT EXISTS (
SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id
)
LIMIT ?
''',
variables: [Variable.withInt(_maxIdsPerSweep)],
).get();
final ids = rows.map((r) => r.read<String>('id')).toList();
if (ids.isEmpty) return;
final api = await _ref.read(libraryApiProvider.future);
for (final id in ids) {
if (_disposed) return;
try {
final result = await api.getAlbum(id);
if (result.tracks.isNotEmpty) {
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedTracks,
result.tracks.map((t) => t.toDrift()).toList(),
);
});
}
} catch (e) {
debugPrint('cache_filler: fillAlbum($id) failed: $e');
}
await Future.delayed(_requestThrottle);
}
}
/// Pre-warm album cover bytes via flutter_cache_manager so the
/// per-tile ServerImage / CachedNetworkImage paints from disk on
/// the user's first visit. Mirrors SyncController's prewarm
/// helper — duplicated here intentionally to keep this file self-
/// contained; a shared helper can come out of #357 follow-up if
/// the duplication grows.
Future<void> _prewarmCovers(List<String> albumIds) 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 mgr = DefaultCacheManager();
var idx = 0;
Future<void> worker() async {
while (idx < albumIds.length) {
if (_disposed) return;
final i = idx++;
try {
await mgr.downloadFile(
'$base/api/albums/${albumIds[i]}/cover',
authHeaders: headers,
);
} catch (_) {
// Best-effort prewarm — 404 (missing collage) and 401
// (token refresh races) shouldn't abort the rest.
}
}
}
// Concurrency 3 — same as SyncController's prewarm. Covers are
// small enough that more parallelism doesn't help much and
// burns radio.
await Future.wait(List.generate(3, (_) => worker()));
}
void dispose() {
_disposed = true;
_initialTimer?.cancel();
_intervalTimer?.cancel();
}
}
/// Read once at app start (from app.dart's postFrameCallback) to
/// activate the filler. Disposed via ref.onDispose when the provider
/// scope tears down; in practice the scope lives for the app's
/// lifetime so dispose only fires on uninstall / process death.
final cacheFillerProvider = Provider<CacheFiller>((ref) {
final filler = CacheFiller(ref);
ref.onDispose(filler.dispose);
filler.start();
return filler;
});
+36 -11
View File
@@ -19,6 +19,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
// `tag` parameter is preserved for future ad-hoc instrumentation.
// Normal operation only logs failure paths so the per-screen log
// noise stays low.
/// Wraps the watch + cold-cache fallback pattern. Generic over:
/// D — the drift row type (or TypedResult for joins)
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
@@ -43,14 +47,23 @@ Stream<T> cacheFirst<D, T>({
// refresh for this stream subscription, so we don't fire one on every
// drift re-emission (otherwise the populate cycles forever).
var revalidated = false;
void log(String msg) {
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
}
// Tracks whether we've already tried a cold-cache fetch on this
// subscription. Without this, providers can spin forever in the
// rows-empty branch when fetchAndPopulate writes rows that don't
// match THIS filter — e.g. three liked-tab streams sharing one
// populate: the track populate writes track-typed rows, drift
// watch fires for the cached_likes table, the album stream
// re-emits with rows-empty (no album likes), the rows-empty
// branch re-fires populate, repeats forever. Setting this guard
// makes the first fetch attempt also the last for any given
// subscription.
var coldFetchAttempted = false;
await for (final rows in driftStream) {
if (rows.isNotEmpty) {
log('drift hit (${rows.length} rows)');
yield toResult(rows);
coldFetchAttempted = true;
// 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.
@@ -62,20 +75,32 @@ Stream<T> cacheFirst<D, T>({
}
continue;
}
log('drift miss; checking connectivity');
// rows is empty. If we've already attempted a cold fetch and
// drift is still empty for this filter, yield empty so the UI
// shows the no-content state instead of spinning forever.
if (coldFetchAttempted) {
yield toResult(rows);
continue;
}
coldFetchAttempted = true;
if (await isOnline()) {
log('online; calling fetchAndPopulate');
try {
await fetchAndPopulate();
log('fetchAndPopulate done; awaiting drift re-emit');
// The drift watch() stream re-emits with the populated rows on
// the next loop iteration. Don't yield here.
// Yield the current (still-empty) rows so the UI moves past
// loading even if populate was a no-op for this filter
// (server returned nothing matching, or all rows were
// already in drift via sync). If populate DID write rows
// matching this filter, the drift watch's next emission
// yields them via the rows.isNotEmpty branch — UI briefly
// shows empty then populates, instead of spinning.
yield toResult(rows);
} catch (e, st) {
log('fetchAndPopulate failed: $e\n$st');
if (tag != null) {
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
}
yield toResult(rows); // empty result; caller surfaces error
}
} else {
log('offline; yielding empty');
yield toResult(rows); // empty result; offline
}
}
+42 -15
View File
@@ -3,17 +3,29 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
/// Operator-tunable cache settings (#357 plan B). Persisted via
/// Operator-tunable cache settings. Persisted via
/// flutter_secure_storage on the same device.
///
/// #427 S2: the single `capBytes` is replaced by two independent
/// budgets — Liked and Rolling-recent — each defaulting to 5GB.
/// Bucketing is by liked-ness (a cached track currently in the
/// user's liked set is charged to Liked; everything else to
/// Rolling), so the dedup is storage-only and never filters
/// playback. The old `cache_cap_bytes` key is intentionally not
/// migrated; defaults reapply (a one-time re-tune, not data loss).
class CacheSettings {
const CacheSettings({
required this.capBytes,
required this.likedCapBytes,
required this.rollingCapBytes,
required this.prefetchWindow,
required this.cacheLikedTracks,
});
/// 0 = unlimited.
final int capBytes;
/// Liked-bucket budget. 0 = unlimited.
final int likedCapBytes;
/// Rolling (recently-played) budget. 0 = unlimited.
final int rollingCapBytes;
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
final int prefetchWindow;
@@ -22,25 +34,31 @@ class CacheSettings {
final bool cacheLikedTracks;
CacheSettings copyWith({
int? capBytes,
int? likedCapBytes,
int? rollingCapBytes,
int? prefetchWindow,
bool? cacheLikedTracks,
}) =>
CacheSettings(
capBytes: capBytes ?? this.capBytes,
likedCapBytes: likedCapBytes ?? this.likedCapBytes,
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
);
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
static const defaults = CacheSettings(
capBytes: 5 * 1024 * 1024 * 1024,
likedCapBytes: _fiveGiB,
rollingCapBytes: _fiveGiB,
prefetchWindow: 5,
cacheLikedTracks: true,
);
}
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
static const _kCap = 'cache_cap_bytes';
static const _kLikedCap = 'cache_liked_cap_bytes';
static const _kRollingCap = 'cache_rolling_cap_bytes';
static const _kPrefetch = 'cache_prefetch_window';
static const _kCacheLiked = 'cache_liked_tracks';
@@ -49,13 +67,17 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
@override
Future<CacheSettings> build() async {
_storage = ref.read(secureStorageProvider);
final cap = await _storage.read(key: _kCap);
final likedCap = await _storage.read(key: _kLikedCap);
final rollingCap = await _storage.read(key: _kRollingCap);
final pre = await _storage.read(key: _kPrefetch);
final liked = await _storage.read(key: _kCacheLiked);
return CacheSettings(
capBytes: cap == null
? CacheSettings.defaults.capBytes
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
likedCapBytes: likedCap == null
? CacheSettings.defaults.likedCapBytes
: int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
rollingCapBytes: rollingCap == null
? CacheSettings.defaults.rollingCapBytes
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
prefetchWindow: pre == null
? CacheSettings.defaults.prefetchWindow
: (int.tryParse(pre) ?? 5).clamp(1, 10),
@@ -65,9 +87,14 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
);
}
Future<void> setCapBytes(int bytes) async {
await _storage.write(key: _kCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(capBytes: bytes));
Future<void> setLikedCapBytes(int bytes) async {
await _storage.write(key: _kLikedCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(likedCapBytes: bytes));
}
Future<void> setRollingCapBytes(int bytes) async {
await _storage.write(key: _kRollingCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes));
}
Future<void> setPrefetchWindow(int n) async {
+191 -1
View File
@@ -101,6 +101,11 @@ class AudioCacheIndex extends Table {
TextColumn get path => text()();
IntColumn get sizeBytes => integer()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
/// When the track was last played. Drives the offline "Recently
/// played" ordering and rolling-bucket LRU eviction. Distinct from
/// cachedAt (download time). Nullable for pre-schema-9 rows until
/// the next play touches them (migration backfills to cachedAt).
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
TextColumn get source => textEnum<CacheSource>()();
@override
Set<Column> get primaryKey => {trackId};
@@ -115,6 +120,130 @@ 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};
}
/// Single-row cache of the /api/me/system-playlists-status response.
/// The home Playlists row reads this every render to pick between
/// real cards and placeholder cards for For-You / Discover / Songs-
/// like slots, so a fresh-mount cold fetch produces a visible
/// "building / pending / failed" flicker. Storing the last result
/// as a JSON blob means the home renders with the prior status
/// instantly, then SWR refreshes underneath. Schema 7+.
class CachedSystemPlaylistsStatus 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};
}
/// Outbound mutation queue for offline-resilient REST calls. Likes,
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
/// create/cancel all enqueue here on REST failure so the user's
/// intent persists across network loss. MutationReplayer drains on
/// connectivity transitions and a 1-minute periodic tick; entries
/// with attempts >= 5 are skipped (the server treats them as
/// permanent failures and library_changes sync will correct any
/// resulting drift drift on next sync). Schema 8+.
class CachedMutations extends Table {
IntColumn get id => integer().autoIncrement()();
/// Stable kind string (see mutation_queue.dart constants). The
/// replayer looks up the handler in a kind→Function map; unknown
/// kinds are dropped to avoid getting stuck.
TextColumn get kind => text()();
/// JSON-encoded payload — args needed to replay the REST call.
/// Shape depends on kind; see mutation_queue.dart.
TextColumn get payload => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
/// When the last drain attempt fired against this row. Null until
/// the first attempt. Useful for diagnostic queries.
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
IntColumn get attempts => integer().withDefault(const Constant(0))();
}
/// 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};
}
/// Single-row snapshot of the last playback session — queue (TrackRef
/// JSON), current index, position, and #415 source. Lets a torn-down
/// session (the #52 idle/dismissed teardown) resume on next launch;
/// without it the headset / lock-screen play button has nothing to
/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern.
/// Schema 10+.
class CachedResumeState 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};
}
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
@DriftDatabase(tables: [
@@ -126,12 +255,19 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
CachedPlaylistTracks,
AudioCacheIndex,
SyncMetadata,
CachedHomeSnapshot,
CachedHistorySnapshot,
CachedQuarantineMine,
CachedHomeIndex,
CachedSystemPlaylistsStatus,
CachedMutations,
CachedResumeState,
])
class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 2;
int get schemaVersion => 10;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -147,6 +283,60 @@ 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);
}
if (from < 7) {
// Schema 7: cached_system_playlists_status. Single-row
// snapshot of /api/me/system-playlists-status used by the
// home Playlists row. Empty on upgrade; first fetch
// populates.
await m.createTable(cachedSystemPlaylistsStatus);
}
if (from < 8) {
// Schema 8: cached_mutations outbound queue. Empty on
// upgrade; populated by controllers when REST calls fail.
await m.createTable(cachedMutations);
}
if (from < 9) {
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
// gives the offline "Recently played" view a real
// recency signal (cachedAt is download time, not play
// time). Nullable — backfilled to cachedAt so existing
// rows order sensibly until next play touches them.
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
await customStatement(
'UPDATE audio_cache_index SET last_played_at = cached_at',
);
}
if (from < 10) {
// Schema 10 (#54): cached_resume_state — single-row last-
// session snapshot for resume-on-launch. Empty on upgrade;
// first persist populates it.
await m.createTable(cachedResumeState);
}
},
);
}
+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);
});
-2
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart';
@@ -38,7 +37,6 @@ class MetadataPrefetcher {
if (n++ >= _topN) break;
_swallow(_ref.read(artistProvider(id).future));
}
if (n > 0) debugPrint('metadataPrefetcher: warming $n artists');
}
void _warmHome(HomeData h) {
+311
View File
@@ -0,0 +1,311 @@
// Outbound mutation queue for offline-resilient REST calls.
//
// Controllers (LikesController, MyQuarantineController, the add-to-
// playlist sheet, the Discover request flow, the Requests cancel
// action) write their optimistic local state to drift first and try
// the corresponding REST call. On failure they enqueue here. The
// replayer drains the queue when connectivity comes back; the user's
// intent persists across network loss without rolling back their
// visible action.
//
// Why this is separate from SyncController: sync ingests AUTHORITATIVE
// SERVER state into drift. The mutation queue carries USER INTENT
// outbound to the server. They flow in opposite directions and
// shouldn't be conflated — sync wins on conflict (server is the
// source of truth), and a mutation that fails forever (5 attempts)
// is dropped on the trust that next sync will reconcile drift to
// match the server's actual state.
//
// Drop semantics: 5 attempts then drop. The drop is intentional —
// holding onto a forever-failing mutation just delays sync's
// reconciliation. The user's optimistic drift state will diverge
// from server, and the next library_changes delta corrects it.
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart' show DioException, DioExceptionType;
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/discover.dart';
import '../api/endpoints/events.dart';
import '../api/endpoints/likes.dart';
import '../library/library_providers.dart' show dioProvider;
import '../likes/likes_provider.dart' show likesApiProvider;
import '../models/lidarr.dart' show LidarrRequestKind;
import '../playlists/playlists_provider.dart' show playlistsApiProvider;
import '../quarantine/quarantine_provider.dart' show quarantineApiProvider;
import '../requests/requests_provider.dart' show requestsApiProvider;
import 'audio_cache_manager.dart' show appDbProvider;
import 'connectivity_provider.dart';
import 'db.dart';
/// Stable kind constants. Each controller emits one of these via
/// MutationQueue.enqueue; the replayer dispatches by kind.
class MutationKinds {
MutationKinds._();
static const likeAdd = 'like.add';
static const likeRemove = 'like.remove';
static const quarantineFlag = 'quarantine.flag';
static const quarantineUnflag = 'quarantine.unflag';
static const playlistAppend = 'playlist.append';
static const requestCreate = 'request.create';
static const requestCancel = 'request.cancel';
static const playOffline = 'play.offline';
}
class MutationQueue {
MutationQueue(this._ref);
final Ref _ref;
/// Persist a mutation for replay. Caller has already done the
/// optimistic local mutation (drift write, in-memory state update)
/// — this only records the REST call that needs to fire eventually.
Future<void> enqueue(String kind, Map<String, dynamic> payload) async {
final db = _ref.read(appDbProvider);
await db.into(db.cachedMutations).insert(
CachedMutationsCompanion.insert(
kind: kind,
payload: jsonEncode(payload),
),
);
// Nudge the replayer in case we're online right now.
unawaited(_ref.read(mutationReplayerProvider).drain());
}
/// Count of pending mutations (attempts < 5). Diagnostic / future
/// UI surface for a "syncing N pending changes" indicator.
Future<int> pendingCount() async {
final db = _ref.read(appDbProvider);
final rows = await db.customSelect(
'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5',
).get();
return rows.first.read<int>('c');
}
}
final mutationQueueProvider =
Provider<MutationQueue>((ref) => MutationQueue(ref));
class MutationReplayer {
MutationReplayer(this._ref);
final Ref _ref;
Timer? _initialTimer;
Timer? _intervalTimer;
bool _running = false;
bool _disposed = false;
/// Periodic poll cadence. The connectivity listener catches most
/// transitions; this is the belt-and-suspenders for cases where
/// connectivity_plus misses a state change (e.g. flaky Wi-Fi).
static const _interval = Duration(minutes: 1);
/// Max replay attempts before we drop a mutation. 5 is enough to
/// span a few reconnection cycles without holding onto a forever-
/// broken request. See file header for the drop rationale.
static const _maxAttempts = 5;
void start() {
// Drain shortly after launch in case there are queued mutations
// from a prior session that died offline. 3s lets the auth +
// server-url + dio providers finish their async warmup.
_initialTimer = Timer(const Duration(seconds: 3), drain);
// Periodic ticker is the only reconnect signal. We previously had
// a ref.listen(connectivityProvider, …) edge trigger here, but
// subscribing at start-time eagerly mounted the connectivity
// StreamProvider and leaked its initial-timeout Timer through
// tests that never reach the auth state. The drain() loop itself
// gates on connectivity via the .future read below.
_intervalTimer = Timer.periodic(_interval, (_) => drain());
}
/// Walk the queue oldest-first, replaying each mutation. Stops on
/// any transient network error (we'll retry on the next tick); a
/// permanent error (404 etc.) increments the attempt counter and
/// moves on to the next mutation. This is public so MutationQueue.
/// enqueue can trigger an immediate attempt after writing.
Future<void> drain() async {
if (_disposed || _running) return;
final online = await _ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
if (!online) return;
_running = true;
try {
while (!_disposed) {
final db = _ref.read(appDbProvider);
final next = await (db.select(db.cachedMutations)
..where((t) => t.attempts.isSmallerThanValue(_maxAttempts))
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])
..limit(1))
.getSingleOrNull();
if (next == null) break;
final handler = _handlers[next.kind];
if (handler == null) {
debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping');
await (db.delete(db.cachedMutations)
..where((t) => t.id.equals(next.id)))
.go();
continue;
}
Map<String, dynamic> payload;
try {
payload = jsonDecode(next.payload) as Map<String, dynamic>;
} catch (e) {
debugPrint('mutation_replayer: bad payload for ${next.kind}: $e');
await (db.delete(db.cachedMutations)
..where((t) => t.id.equals(next.id)))
.go();
continue;
}
try {
await handler(_ref, payload);
await (db.delete(db.cachedMutations)
..where((t) => t.id.equals(next.id)))
.go();
} on DioException catch (e) {
await (db.update(db.cachedMutations)
..where((t) => t.id.equals(next.id)))
.write(CachedMutationsCompanion(
attempts: Value(next.attempts + 1),
lastAttemptAt: Value(DateTime.now()),
));
if (_isTransient(e)) {
// Network is flaky again — bail and try the whole queue
// later. The current mutation will be retried next tick.
break;
}
// Permanent failure (4xx/5xx with a real response). Leave
// the row with bumped attempts and move on to the next so
// one bad mutation doesn't block the others.
} catch (e, st) {
debugPrint('mutation_replayer: unexpected error: $e\n$st');
await (db.update(db.cachedMutations)
..where((t) => t.id.equals(next.id)))
.write(CachedMutationsCompanion(
attempts: Value(next.attempts + 1),
lastAttemptAt: Value(DateTime.now()),
));
}
}
} finally {
_running = false;
}
}
bool _isTransient(DioException e) {
switch (e.type) {
case DioExceptionType.connectionError:
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return true;
default:
return false;
}
}
void dispose() {
_disposed = true;
_initialTimer?.cancel();
_intervalTimer?.cancel();
}
}
final mutationReplayerProvider = Provider<MutationReplayer>((ref) {
final r = MutationReplayer(ref);
ref.onDispose(r.dispose);
r.start();
return r;
});
/// Type signature for kind-specific replay handlers.
typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
/// Kind → handler dispatch. Each handler decodes its payload and
/// re-fires the original REST call. Throws on failure (caught by the
/// replayer's drain loop above).
///
/// Payload shapes (see also enqueue call sites for the writer side):
/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid}
/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str}
/// * quarantine.unflag: {'trackId': uuid}
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
/// * request.create: {full createRequest args; see DiscoverApi}
/// * request.cancel: {'id': uuid}
/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601,
/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'}
final Map<String, _Handler> _handlers = {
MutationKinds.likeAdd: (ref, p) async {
final api = await ref.read(likesApiProvider.future);
await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String);
},
MutationKinds.likeRemove: (ref, p) async {
final api = await ref.read(likesApiProvider.future);
await api.unlike(
_likeKindFromString(p['kind'] as String), p['id'] as String);
},
MutationKinds.quarantineFlag: (ref, p) async {
final api = await ref.read(quarantineApiProvider.future);
await api.flag(
p['trackId'] as String,
p['reason'] as String,
notes: (p['notes'] as String?) ?? '',
);
},
MutationKinds.quarantineUnflag: (ref, p) async {
final api = await ref.read(quarantineApiProvider.future);
await api.unflag(p['trackId'] as String);
},
MutationKinds.playlistAppend: (ref, p) async {
final api = await ref.read(playlistsApiProvider.future);
final ids = (p['trackIds'] as List).cast<String>();
await api.appendTracks(p['playlistId'] as String, ids);
},
MutationKinds.requestCreate: (ref, p) async {
final dio = await ref.read(dioProvider.future);
final api = DiscoverApi(dio);
await api.createRequest(
kind: _lidarrKindFromString(p['kind'] as String),
artistMbid: p['artistMbid'] as String,
artistName: p['artistName'] as String,
albumMbid: p['albumMbid'] as String?,
albumTitle: p['albumTitle'] as String?,
trackMbid: p['trackMbid'] as String?,
trackTitle: p['trackTitle'] as String?,
);
},
MutationKinds.requestCancel: (ref, p) async {
final api = await ref.read(requestsApiProvider.future);
await api.cancel(p['id'] as String);
},
MutationKinds.playOffline: (ref, p) async {
final dio = await ref.read(dioProvider.future);
final api = EventsApi(dio);
await api.playOffline(
trackId: p['trackId'] as String,
clientId: p['clientId'] as String,
atIso: p['at'] as String,
durationPlayedMs: (p['durationPlayedMs'] as num).toInt(),
source: p['source'] as String?,
);
},
};
LikeKind _likeKindFromString(String s) => switch (s) {
'album' => LikeKind.album,
'artist' => LikeKind.artist,
_ => LikeKind.track,
};
LidarrRequestKind _lidarrKindFromString(String s) => switch (s) {
'album' => LidarrRequestKind.album,
'track' => LidarrRequestKind.track,
_ => LidarrRequestKind.artist,
};
+105
View File
@@ -0,0 +1,105 @@
// Reachability-based offline marker (#427 S1).
//
// `connectivityProvider` only knows whether a network *interface* is
// up — not whether the Minstrel server is actually reachable (captive
// portals, server down, DNS, VPN-only routes all read "online"). This
// is the single source of truth other features gate on: offline =
// the server failed its /healthz probe N times in a row; recovery on
// the first success.
//
// Deliberately NOT coupled to connectivityProvider: subscribing to
// that StreamProvider eagerly mounts its 2s checkConnectivity timeout
// and leaks a pending Timer through widget tests that never reach the
// auth state (the bug fixed for MutationReplayer). /healthz failing
// already covers interface-down — it just fails the probe.
//
// Optimistic: assume online until proven offline, so a cold launch
// behaves normally and only flips after sustained unreachability.
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/health.dart';
import '../library/library_providers.dart' show dioProvider;
class OfflineMonitor extends Notifier<bool> {
Timer? _initial;
Timer? _periodic;
int _consecutiveFails = 0;
/// Consecutive failed probes before we declare offline. Small
/// enough to react within ~half a minute, large enough that a
/// single dropped request doesn't flip the whole UI.
static const _threshold = 3;
/// Probe cadence. Slower when believed-online (cheap heartbeat);
/// faster when offline so recovery is noticed quickly.
static const _onlineInterval = Duration(seconds: 30);
static const _offlineInterval = Duration(seconds: 10);
/// Let auth + server-url + dio providers finish async warmup
/// before the first probe so a cold start doesn't false-positive.
static const _initialDelay = Duration(seconds: 5);
@override
bool build() {
ref.onDispose(() {
_initial?.cancel();
_periodic?.cancel();
});
_initial = Timer(_initialDelay, () {
_check();
_arm();
});
return false; // optimistic until a probe says otherwise
}
void _arm() {
_periodic?.cancel();
_periodic = Timer.periodic(
state ? _offlineInterval : _onlineInterval,
(_) => _check(),
);
}
Future<void> _check() async {
final Dio dio;
try {
// Not configured yet (no server URL) → don't flip; the app is
// still on the connect screen and "offline" is meaningless.
dio = await ref.read(dioProvider.future);
} catch (_) {
return;
}
try {
// dio's own connect/receive timeouts bound this — no extra
// Timer (which would leak in widget tests).
await HealthApi(dio).check();
_consecutiveFails = 0;
_set(false);
} catch (_) {
_consecutiveFails++;
if (_consecutiveFails >= _threshold) {
_set(true);
}
}
}
void _set(bool offline) {
if (state == offline) return;
state = offline;
_arm(); // cadence follows the new state
}
/// Force an immediate probe — e.g. a user-initiated retry. Public
/// so S4's offline surfaces can offer a "try again" affordance.
Future<void> recheck() => _check();
}
/// `true` when the server is unreachable. Watch this to gate
/// online-only affordances; read it once at app start to activate
/// the poller.
final offlineProvider =
NotifierProvider<OfflineMonitor, bool>(OfflineMonitor.new);
+55 -10
View File
@@ -1,6 +1,10 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../player/album_color_extractor.dart';
import '../player/player_provider.dart';
import 'audio_cache_manager.dart';
import 'cache_settings_provider.dart';
@@ -39,20 +43,61 @@ class Prefetcher {
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
final mgr = _ref.read(audioCacheManagerProvider);
final coverCache = _ref.read(albumCoverCacheProvider);
final colorCache = _ref.read(albumColorCacheProvider);
// Walk the window. For each upcoming track we want THREE things
// ready when the player transitions into it:
//
// 1. Audio file on disk (otherwise playback stalls on stream
// load — the original prefetcher concern).
// 2. Cover bytes on disk under AlbumCoverCache (otherwise
// _toMediaItem's peekCached returns null, mediaItem
// broadcasts with artUri=null, and the now-playing screen
// stalls in _scheduleSwap awaiting precacheImage of a file
// that doesn't exist yet).
// 3. Palette color extracted and memoized in AlbumColorCache
// (otherwise the gradient backdrop has to wait for
// PaletteGenerator to run after the cover lands —
// visible as the cover snapping in before the gradient).
//
// Each call is idempotent (cache-aware): if already cached, it's
// a no-op. Everything fire-and-forget so the reconcile completes
// quickly even on a fresh queue.
for (var i = currentIdx; i <= endIdx; i++) {
final trackId = queue[i].id;
if (await mgr.isCached(trackId)) continue;
// Fire-and-forget; downloads happen in the background. Errors
// inside pin() are caught + cleaned up internally.
// ignore: unawaited_futures
mgr.pin(trackId, source: CacheSource.autoPrefetch);
final media = queue[i];
final trackId = media.id;
final albumId = media.extras?['album_id'] as String?;
if (!await mgr.isCached(trackId)) {
// ignore: unawaited_futures
mgr.pin(trackId, source: CacheSource.autoPrefetch);
}
if (albumId != null && albumId.isNotEmpty) {
// Cover bytes: getOrFetch returns the file path; the side
// effect (writing to disk) is what we care about.
// ignore: unawaited_futures
coverCache.getOrFetch(albumId);
// Palette: getOrExtract chains off coverCache.getOrFetch so
// it'll wait for the cover before sampling — safe to call
// in parallel here.
// ignore: unawaited_futures
colorCache.getOrExtract(albumId);
}
}
// Eviction pass after pinning new files.
if (settings.capBytes > 0) {
await mgr.evict(targetBytes: settings.capBytes);
}
// Eviction pass after pinning new files. Per-bucket (#427 S2):
// liked-ness decides the bucket, so a track that's both liked
// and recently played is protected by the Liked cap, not the
// rolling LRU.
final liked =
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
await mgr.evictBuckets(
likedCap: settings.likedCapBytes,
rollingCap: settings.rollingCapBytes,
liked: liked,
);
}
}
+183
View File
@@ -0,0 +1,183 @@
// Resume-on-launch (#54). The #52 teardown tears the audio_service
// session down (and clears mediaItem) when idle/dismissed, so the
// headset / lock-screen play button otherwise has nothing to resume.
// This controller persists the live queue + index + position + #415
// source to a single-row drift snapshot on track change / pause / app
// teardown, and on construction restores the last snapshot into the
// handler PAUSED (the user sees their last track and continues with
// play / a media button — we never auto-blast on launch).
//
// Persist guard: when the handler clears (teardown → mediaItem null /
// empty queue) we deliberately do NOT write, so the last good snapshot
// survives for the next launch — that survival is the whole point.
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import 'audio_cache_manager.dart' show appDbProvider;
import 'db.dart';
class ResumeController with WidgetsBindingObserver {
ResumeController(this._ref);
final Ref _ref;
final _subs = <StreamSubscription<dynamic>>[];
Timer? _debounce;
bool _disposed = false;
Future<void> start() async {
try {
// audioHandlerProvider throws until main() overrides it (the real
// app always does). In tests / no-audio environments there's
// nothing to resume — stay inert.
_ref.read(audioHandlerProvider);
} catch (_) {
return;
}
if (_disposed) return;
await _restore();
if (_disposed) return;
final h = _ref.read(audioHandlerProvider);
// Media-button-when-torn-down hook (#448): play() invokes this when
// mediaItem is null so a headset/watch press resumes the snapshot.
h.setResumeHook(resumeFromMediaButton);
_subs.add(h.mediaItem.listen((_) => _schedulePersist()));
_subs.add(h.playbackState.listen((_) => _schedulePersist()));
WidgetsBinding.instance.addObserver(this);
}
void _schedulePersist() {
if (_disposed) return;
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 3), () {
unawaited(_persist());
});
}
Future<void> _persist() async {
if (_disposed) return;
try {
final h = _ref.read(audioHandlerProvider);
final tracks = h.queuedTracks;
final mi = h.mediaItem.value;
// Cleared by teardown — keep the last good snapshot for next
// launch rather than wiping it with an empty queue.
if (tracks.isEmpty || mi == null) return;
var idx = tracks.indexWhere((t) => t.id == mi.id);
if (idx < 0) idx = 0;
final blob = jsonEncode({
'v': 1,
'source': h.queueSource,
'index': idx,
'position_ms': h.position.inMilliseconds,
'tracks': tracks.map((t) => t.toJson()).toList(),
});
final db = _ref.read(appDbProvider);
await db.into(db.cachedResumeState).insertOnConflictUpdate(
CachedResumeStateCompanion.insert(
json: blob,
updatedAt: drift.Value(DateTime.now()),
),
);
} catch (e, st) {
debugPrint('resume_controller: persist failed: $e\n$st');
}
}
/// Launch path: restore the last session PAUSED (no auto-blast).
Future<void> _restore() async {
try {
await _loadAndRestore();
} catch (e, st) {
debugPrint('resume_controller: restore failed: $e\n$st');
}
}
/// Media-button path (#448): the user pressed play on the headset /
/// watch / lock screen while the session was fully torn down (#52) and
/// nothing is loaded. Restore the snapshot, then START playback (they
/// asked to play). Registered as the handler's resume hook in start().
/// No-op if there's nothing to resume.
Future<void> resumeFromMediaButton() async {
try {
if (await _loadAndRestore()) {
await _ref.read(audioHandlerProvider).play();
}
} catch (e, st) {
debugPrint('resume_controller: media-button resume failed: $e\n$st');
}
}
/// Shared loader. Restores the last persisted session PAUSED and
/// returns whether it actually restored a queue. Guards: an already-
/// active session (don't stomp), missing auth, no/empty snapshot.
Future<bool> _loadAndRestore() async {
final h = _ref.read(audioHandlerProvider);
if (h.mediaItem.value != null) return false;
final url = await _ref.read(serverUrlProvider.future);
final token =
await _ref.read(secureStorageProvider).read(key: 'session_token');
if (url == null || url.isEmpty || token == null || token.isEmpty) {
return false;
}
final db = _ref.read(appDbProvider);
final row = await db.select(db.cachedResumeState).getSingleOrNull();
if (row == null) return false;
final m = jsonDecode(row.json) as Map<String, dynamic>;
final rawTracks = (m['tracks'] as List?) ?? const [];
final tracks = rawTracks
.map((e) => TrackRef.fromJson(e as Map<String, dynamic>))
.toList();
if (tracks.isEmpty) return false;
final idx = (m['index'] as num?)?.toInt() ?? 0;
final posMs = (m['position_ms'] as num?)?.toInt() ?? 0;
final source = m['source'] as String?;
await _ref.read(playerActionsProvider).restoreQueue(
tracks,
initialIndex: idx,
position: Duration(milliseconds: posMs),
source: source,
);
return true;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// App backgrounded / killed: persist durably right now (the debounce
// may not fire before teardown).
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_debounce?.cancel();
unawaited(_persist());
}
}
void dispose() {
_disposed = true;
_debounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
for (final s in _subs) {
s.cancel();
}
_subs.clear();
}
}
/// Read once at app start (app.dart postFrame). On construction it
/// restores the last persisted session (paused) then persists
/// queue/index/position on track change, pause, and app teardown.
/// Disposed via ref.onDispose when the scope tears down.
final resumeControllerProvider = Provider<ResumeController>((ref) {
final c = ResumeController(ref);
ref.onDispose(c.dispose);
// ignore: unawaited_futures
c.start();
return c;
});
+104
View File
@@ -0,0 +1,104 @@
// Offline play sources over the local cache index (#427 S4).
//
// "Shuffle all" is always-present and degrades with reachability:
// online → GET /api/library/shuffle (random over the whole library)
// offline → a client shuffle over the entire local cache index
//
// Offline-only pools surfaced on Home beside the (disabled) system
// playlists: "Recently played" (cache by lastPlayedAt desc) and
// "Liked" (cache ∩ liked set). All of these are UNIONs over the
// cache regardless of storage bucket — liked AND recently-played
// both included. The two-bucket split (S2) is storage/eviction-only
// and never filters playback, which is exactly what this relies on.
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../library/library_providers.dart' show libraryApiProvider;
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../models/track.dart';
import 'adapters.dart';
import 'audio_cache_manager.dart' show appDbProvider;
import 'offline_provider.dart';
class ShuffleSource {
ShuffleSource(this._ref);
final Ref _ref;
/// Shuffle-all. Online: server-random. Offline: every cached
/// track, shuffled. Empty offline → nothing cached yet.
Future<List<TrackRef>> tracks({int limit = 100}) async {
if (_ref.read(offlineProvider)) {
final ids = await _cachedIdsByRecency();
final list = await _refs(ids);
list.shuffle(Random());
return list.length > limit ? list.sublist(0, limit) : list;
}
final api = await _ref.read(libraryApiProvider.future);
return api.shuffle(limit: limit);
}
/// Cached tracks, most-recently-played first (liked included).
/// Cache-only — surfaced on Home only when offline.
Future<List<TrackRef>> recentlyPlayed({int limit = 100}) async {
final ids = await _cachedIdsByRecency();
final list = await _refs(ids);
return list.length > limit ? list.sublist(0, limit) : list;
}
/// Cached tracks that are in the user's liked set. Cache-only —
/// surfaced on Home only when offline.
Future<List<TrackRef>> liked({int limit = 100}) async {
final likedSet =
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
final ids =
(await _cachedIdsByRecency()).where(likedSet.contains).toList();
final list = await _refs(ids);
return list.length > limit ? list.sublist(0, limit) : list;
}
/// Cached track ids ordered by lastPlayedAt desc (nulls last so
/// never-touched downloads sort after real plays).
Future<List<String>> _cachedIdsByRecency() async {
final db = _ref.read(appDbProvider);
final rows = await db.select(db.audioCacheIndex).get();
rows.sort((a, b) {
final av = a.lastPlayedAt, bv = b.lastPlayedAt;
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
return bv.compareTo(av);
});
return rows.map((r) => r.trackId).toList();
}
/// Materializes ordered track ids into TrackRefs from the cached
/// metadata tables, preserving the given order.
Future<List<TrackRef>> _refs(List<String> orderedIds) async {
if (orderedIds.isEmpty) return const [];
final db = _ref.read(appDbProvider);
final meta = await (db.select(db.cachedTracks)
..where((t) => t.id.isIn(orderedIds)))
.get();
if (meta.isEmpty) return const [];
final byId = {for (final t in meta) t.id: t};
final artistName = {
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
};
final albumTitle = {
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
};
return [
for (final id in orderedIds)
if (byId[id] case final t?)
t.toRef(
artistName: artistName[t.artistId] ?? '',
albumTitle: albumTitle[t.albumId] ?? '',
)
];
}
}
final shuffleSourceProvider =
Provider<ShuffleSource>((ref) => ShuffleSource(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,
+144
View File
@@ -0,0 +1,144 @@
// 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' as drift show OrderingTerm;
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.
///
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
/// artist carries a representative album id; toRef() reconstructs
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
/// has an empty coverUrl and tiles fall back to the music-notes
/// placeholder even though the server has the cover available.
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)))
.join([
leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
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;
}
// First row has the alphabetically-first album (or null if artist
// has no albums in drift yet — yields a coverless ArtistRef which
// the UI falls back to the placeholder icon for).
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
}
});
/// 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 ?? '',
);
}
});
+220 -21
View File
@@ -1,11 +1,15 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../api/endpoints/discover.dart';
import '../api/errors.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/artist_suggestion.dart';
import '../models/lidarr.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
@@ -25,6 +29,22 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
final _ctrl = TextEditingController();
LidarrRequestKind _kind = LidarrRequestKind.artist;
Future<List<LidarrSearchResult>>? _resultsFuture;
// Default (empty-search) surface: LB-derived out-of-library artist
// suggestions, mirroring web's SuggestionFeed.
Future<List<ArtistSuggestion>>? _suggestionsFuture;
final _requested = <String>{};
@override
void initState() {
super.initState();
_loadSuggestions();
// Clearing the box returns to suggestions (web swaps live too).
_ctrl.addListener(() {
if (_ctrl.text.trim().isEmpty && _resultsFuture != null) {
setState(() => _resultsFuture = null);
}
});
}
@override
void dispose() {
@@ -32,6 +52,55 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
super.dispose();
}
// Assigns the future only; callers trigger the rebuild (initState
// runs before first build, so setState here would be a no-op/warn).
void _loadSuggestions() {
_suggestionsFuture = ref
.read(_discoverApiProvider.future)
.then((api) => api.listSuggestions());
}
Future<void> _requestSuggestion(ArtistSuggestion s) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final args = {
'kind': LidarrRequestKind.artist.wire,
'artistMbid': s.mbid,
'artistName': s.name,
'albumMbid': null,
'albumTitle': null,
};
try {
final api = await ref.read(_discoverApiProvider.future);
await api.createRequest(
kind: LidarrRequestKind.artist,
artistMbid: s.mbid,
artistName: s.name,
);
if (mounted) {
// Reassign the future first; the setState below rebuilds and
// the FutureBuilder picks up the fresh fetch (server now
// filters this candidate out). _requested hides it meanwhile.
_loadSuggestions();
setState(() => _requested.add(s.mbid));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Requested: ${s.name}'),
backgroundColor: fs.iron,
));
}
} on DioException catch (_) {
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.requestCreate, args);
if (mounted) {
setState(() => _requested.add(s.mbid));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Request queued: ${s.name}'),
backgroundColor: fs.iron,
));
}
}
}
void _runSearch() {
final q = _ctrl.text.trim();
if (q.isEmpty) {
@@ -47,14 +116,23 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
Future<void> _request(LidarrSearchResult row) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final args = {
'kind': _kind.wire,
'artistMbid':
_kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
'artistName':
_kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null,
'albumTitle': _kind == LidarrRequestKind.album ? row.name : null,
};
try {
final api = await ref.read(_discoverApiProvider.future);
await api.createRequest(
kind: _kind,
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
artistMbid: args['artistMbid'] as String,
artistName: args['artistName'] as String,
albumMbid: args['albumMbid'],
albumTitle: args['albumTitle'],
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
@@ -64,12 +142,18 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
// Re-run search to refresh the `requested` flag on the row.
_runSearch();
}
} on DioException catch (e) {
} on DioException catch (_) {
// Queue for replay so the user's request persists across
// network loss. We don't have a drift table for in-flight
// requests yet, so the row won't show on the Requests screen
// until replay succeeds — accept that for v1.
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.requestCreate, args);
if (mounted) {
final code = ApiError.fromDio(e).code;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Request failed: $code'),
backgroundColor: fs.error,
content: Text('Request queued: ${row.name}'),
backgroundColor: fs.iron,
));
}
}
@@ -84,7 +168,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Discover', style: TextStyle(color: fs.parchment)),
@@ -110,7 +194,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
borderSide: BorderSide(color: fs.accent),
),
suffixIcon: IconButton(
icon: Icon(Icons.search, color: fs.ash),
icon: Icon(LucideIcons.search, color: fs.ash),
onPressed: _runSearch,
),
),
@@ -136,13 +220,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
),
Expanded(
child: _resultsFuture == null
? Center(
child: Text(
'Type to search, then tap Request to send to Lidarr.',
textAlign: TextAlign.center,
style: TextStyle(color: fs.ash),
),
)
? _buildSuggestions(fs)
: FutureBuilder<List<LidarrSearchResult>>(
future: _resultsFuture,
builder: (ctx, snap) {
@@ -182,6 +260,62 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
]),
);
}
Widget _buildSuggestions(FabledSwordTheme fs) {
return FutureBuilder<List<ArtistSuggestion>>(
future: _suggestionsFuture,
builder: (ctx, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final items = (snap.data ?? const <ArtistSuggestion>[])
.where((s) => !_requested.contains(s.mbid))
.toList(growable: false);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Suggested for you',
style: TextStyle(
color: fs.parchment,
fontSize: 20,
fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
"Out-of-library artists drawn from what you've liked and played.",
style: TextStyle(color: fs.ash, fontSize: 13),
),
],
),
),
if (snap.hasError)
Padding(
padding: const EdgeInsets.all(16),
child: Text("Couldn't load suggestions.",
style: TextStyle(color: fs.ash)),
)
else if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Listen to something or like an artist to start getting suggestions.',
style: TextStyle(color: fs.ash),
),
)
else
...items.map((s) => _SuggestionTile(
s: s,
onRequest: () => _requestSuggestion(s),
)),
],
);
},
);
}
}
class _ResultTile extends StatelessWidget {
@@ -203,10 +337,15 @@ class _ResultTile extends StatelessWidget {
height: 56,
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)),
? Icon(LucideIcons.disc_3, color: fs.ash)
: CachedNetworkImage(
imageUrl: row.imageUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(LucideIcons.disc_3, color: fs.ash),
),
),
),
const SizedBox(width: 12),
@@ -262,3 +401,63 @@ class _Pill extends StatelessWidget {
);
}
}
class _SuggestionTile extends StatelessWidget {
const _SuggestionTile({required this.s, required this.onRequest});
final ArtistSuggestion s;
final VoidCallback onRequest;
@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: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 56,
height: 56,
color: fs.slate,
child: s.imageUrl.isEmpty
? Icon(LucideIcons.user, color: fs.ash)
: CachedNetworkImage(
imageUrl: s.imageUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(LucideIcons.user, color: fs.ash),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14)),
if (s.attributionText.isNotEmpty)
Text(s.attributionText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onRequest,
style: FilledButton.styleFrom(
backgroundColor: fs.accent,
foregroundColor: fs.parchment,
),
child: const Text('Request'),
),
]),
);
}
}
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.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';
@@ -113,26 +112,11 @@ class AlbumDetailScreen extends ConsumerWidget {
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
],
)),
IconButton(
key: const Key('download_album_button'),
icon: Icon(Icons.download, color: fs.ash),
tooltip: 'Download album',
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in r.tracks) {
// ignore: unawaited_futures
mgr.pin(t.id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
);
},
),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
icon: Icon(LucideIcons.play, color: fs.parchment),
onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks),
),
),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -90,13 +91,11 @@ class ArtistDetailScreen extends ConsumerWidget {
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
child: IconButton(
icon: Icon(Icons.play_arrow, color: fs.parchment),
icon: Icon(LucideIcons.play, color: fs.parchment),
onPressed: () async {
try {
final tracks =
await ref.read(artistTracksProvider(id).future);
debugPrint(
'artist_detail: play tapped — ${tracks.length} tracks for $id');
if (tracks.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+304 -106
View File
@@ -1,19 +1,23 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
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/offline_provider.dart';
import '../cache/shuffle_source.dart';
import '../cache/tile_providers.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.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';
@@ -21,13 +25,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(
@@ -39,40 +48,34 @@ 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: () => _HomeSkeleton(fs: fs),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
onRefresh: () async => ref.refresh(homeIndexProvider.future),
child: ListView(
// ClampingScrollPhysics: no bounce overscroll past the
// content. Combined with the bottom padding below, this
// makes "scroll to end" land the last item just above the
// player bar — no empty void.
physics: const ClampingScrollPhysics(),
children: [
_PlaylistsSection(
playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
offline: ref.watch(offlineProvider),
),
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
_RediscoverSection(
albums: h.rediscoverAlbums,
artists: h.rediscoverArtists,
albumIds: h.rediscoverAlbums,
artistIds: h.rediscoverArtists,
),
_MostPlayedSection(tracks: h.mostPlayedTracks),
_LastPlayedSection(artists: h.lastPlayedArtists),
// Bottom padding ≈ player bar height (top row 80 + seek
// 30 + padding 16 ≈ ~140) so the last section's bottom
// edge lands right above the player when scrolled.
_MostPlayedSection(ids: h.mostPlayedTracks),
_LastPlayedSection(ids: h.lastPlayedArtists),
const SizedBox(height: 140),
],
),
@@ -83,24 +86,265 @@ 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});
const _PlaylistsSection({
required this.playlists,
required this.status,
required this.offline,
});
final List<Playlist> playlists;
final SystemPlaylistsStatus status;
final bool offline;
@override
Widget build(BuildContext context) {
final items = _buildPlaylistsRow(playlists, status);
final children = <Widget>[
// Offline: surface the cache-backed pools where the (now
// play-disabled) system playlists sit, so there's something
// to play. #427 S4b.
if (offline) ...const [
_OfflinePoolCard(
label: 'Recently played',
icon: LucideIcons.history,
kind: _OfflinePoolKind.recentlyPlayed,
),
_OfflinePoolCard(
label: 'Liked',
icon: LucideIcons.heart,
kind: _OfflinePoolKind.liked,
),
],
for (final item in items)
if (item is _RealPlaylist)
PlaylistCard(playlist: item.playlist)
else
PlaylistPlaceholderCard(
label: (item as _PlaceholderPlaylist).label,
variant: item.variant,
),
];
return HorizontalScrollRow(
title: 'Playlists',
height: 220,
children: items.map((item) {
if (item is _RealPlaylist) {
return PlaylistCard(playlist: item.playlist);
}
final ph = item as _PlaceholderPlaylist;
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
}).toList(),
children: children,
);
}
}
enum _OfflinePoolKind { recentlyPlayed, liked }
/// Home tile for an offline cache-backed pool. Tapping shuffles +
/// plays that pool from the local cache. Sized to match
/// PlaylistCard so the row stays visually consistent.
class _OfflinePoolCard extends ConsumerWidget {
const _OfflinePoolCard({
required this.label,
required this.icon,
required this.kind,
});
final String label;
final IconData icon;
final _OfflinePoolKind kind;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final src = ref.read(shuffleSourceProvider);
final refs = await switch (kind) {
_OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(),
_OfflinePoolKind.liked => src.liked(),
};
if (refs.isEmpty) {
messenger.showSnackBar(
SnackBar(content: Text('No cached $label tracks yet')),
);
return;
}
await ref
.read(playerActionsProvider)
.playTracks(refs, shuffle: true);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 144,
height: 144,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: fs.accent, size: 56),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'Offline',
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
],
),
),
),
),
);
}
}
@@ -129,13 +373,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)
@@ -146,7 +393,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));
}
@@ -162,44 +408,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: () =>
context.push('/albums/${a.id}', extra: a),
))
.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:
@@ -207,47 +447,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: () =>
context.push('/albums/${a.id}', extra: a),
))
.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: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.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(
@@ -255,12 +483,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),
],
),
]);
@@ -268,12 +491,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.',
@@ -282,13 +505,7 @@ class _LastPlayedSection extends StatelessWidget {
return HorizontalScrollRow(
title: 'Last played',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
context.push('/artists/${ar.id}', extra: ar),
))
.toList(),
children: ids.map((id) => _ArtistTile(id: id)).toList(),
);
}
}
@@ -319,11 +536,11 @@ class _EmptySection extends StatelessWidget {
}
}
/// Cold-start skeleton. Renders the same shape as the real home (a few
/// section titles + card-sized placeholders) so the page feels alive
/// while /api/home is in flight, instead of a 30-second blank spinner.
/// Each section drops in independently as data arrives via the
/// individual providers.
/// 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;
@@ -376,26 +593,7 @@ class _SkeletonSection extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: fs.slate,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 8),
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
const SizedBox(height: 4),
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
],
),
),
itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth),
),
),
]);
+209 -18
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
@@ -16,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
@@ -42,27 +44,217 @@ 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().
final artistProvider =
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
// LEFT JOIN cached_albums for cover-URL reconstruction (see
// CachedArtistAdapter.toRef). First row carries the alphabetically-
// first album.
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
return cacheFirst<drift.TypedResult, ArtistRef>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
toResult: (rows) {
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
@@ -101,6 +293,14 @@ final artistAlbumsProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: re-fetch the full album list on every visit. Without this,
// a previously-incomplete drift cache (e.g. user had only opened
// one album by this artist, so cachedAlbums had just that row)
// would render forever as a partial list. The prefetcher only
// warms artistProvider (single row), so this provider isn't
// mass-instantiated and the storm risk that motivated dropping
// alwaysRefresh elsewhere doesn't apply here.
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
@@ -187,11 +387,9 @@ final albumProvider = StreamProvider.family<
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(libraryApiProvider.future);
debugPrint('albumProvider($albumId): calling getAlbum');
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
// Collect every artist mentioned by the album + its tracks so
// the JOINs that drive both the album header and the track rows
// have something to bind to. Without this, drift returns null
@@ -224,7 +422,6 @@ final albumProvider = StreamProvider.family<
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
return true;
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
@@ -235,7 +432,6 @@ final albumProvider = StreamProvider.family<
await for (final albumRows in albumQuery.watch()) {
// Case 1: no album row at all → cold-fetch.
if (albumRows.isEmpty) {
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
if (fetchAttempted) {
// Already tried and got nothing back.
yield (
@@ -245,10 +441,7 @@ final albumProvider = StreamProvider.family<
continue;
}
fetchAttempted = true;
final online = await isOnline();
debugPrint('albumProvider($albumId): online=$online');
if (!online) {
debugPrint('albumProvider($albumId): offline, yielding empty');
if (!await isOnline()) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
@@ -265,7 +458,6 @@ final albumProvider = StreamProvider.family<
// On success, drift watch re-emits with rows; loop continues.
continue;
}
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
final albumRow = albumRows.first;
final album = albumRow.readTable(db.cachedAlbums).toRef(
@@ -280,7 +472,6 @@ final albumProvider = StreamProvider.family<
// fetchAndPopulate so the album becomes complete; drift watch
// re-emits and we land in the populated branch on the next pass.
if (trackRows.isEmpty && !fetchAttempted) {
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
+579 -223
View File
@@ -1,22 +1,35 @@
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/adapters.dart';
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/shuffle_source.dart';
import '../cache/tile_providers.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/album.dart';
import '../models/artist.dart';
import '../models/history_event.dart';
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
// route descriptor) that conflicts with our wire-format Page<T>.
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';
@@ -38,109 +51,294 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
return MeApi(await ref.watch(dioProvider.future));
});
/// 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 {
/// Drift-first all-artists list. Reads cached_artists ordered by
/// sortName; GridView.builder takes care of lazy widget construction
/// so loading the full set up front is fine for typical library sizes.
/// SWR refresh on every subscription hits /api/artists with a generous
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
///
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
/// previous "fetch one page at a time as you scroll" felt like the
/// rest of the app was buffering when this tab was the slow surface,
/// and SyncController already populates the full set of artist rows
/// via /api/library/sync.
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
final db = ref.watch(appDbProvider);
// LEFT JOIN cached_albums so each artist row carries a representative
// album id for cover-URL reconstruction. Sorted by artist sortName
// then album sortTitle: the first row per artist gets the
// alphabetically-first album. Dedup happens in toResult.
final query = db.select(db.cachedArtists).join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([
drift.OrderingTerm.asc(db.cachedArtists.sortName),
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
]);
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
// Cold-cache fallback: sync should have populated drift already,
// but a fresh install + first Library visit before sync completes
// will be empty. Fetch a generous chunk via /api/artists and
// persist; subsequent SWR refreshes keep things current.
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listArtists(
limit: _pageSize,
offset: cur.items.length,
final fresh = await api.listArtists(limit: 1000, offset: 0);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedArtists,
fresh.items.map((a) => a.toDrift()).toList(),
);
});
},
toResult: (rows) {
// The join multiplies rows by album count; dedup by artist id
// keeping the first occurrence so we get one ArtistRef per
// artist with the alphabetically-first album's id for the
// cover-URL projection. Artists with no albums in drift yet
// come through as a single row with null cachedAlbums and an
// empty coverUrl — UI falls back to the placeholder icon.
final seen = <String>{};
final out = <ArtistRef>[];
for (final r in rows) {
final a = r.readTable(db.cachedArtists);
if (!seen.add(a.id)) continue;
final album = r.readTableOrNull(db.cachedAlbums);
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
}
return out;
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'libraryArtists',
);
});
/// Drift-first all-albums list. Joins cached_albums with cached_artists
/// for the artistName field. Same cold-cache fallback + SWR refresh
/// pattern as libraryArtistsProvider.
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedAlbums).join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(_libraryListsApiProvider.future);
final fresh = await api.listAlbums(limit: 1000, offset: 0);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
fresh.items.map((a) => a.toDrift()).toList(),
);
});
},
toResult: (rows) => rows.map((r) {
final album = r.readTable(db.cachedAlbums);
final artist = r.readTableOrNull(db.cachedArtists);
return album.toRef(artistName: artist?.name ?? '');
}).toList(),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'libraryAlbums',
);
});
// 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',
);
});
/// 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)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'likedTrackIds',
);
});
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)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'likedAlbumIds',
);
});
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)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'likedArtistIds',
);
});
/// 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,
);
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;
}
}
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 _libraryArtistsProvider = AsyncNotifierProvider<
_LibraryArtistsNotifier,
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
static const _pageSize = 50;
bool _loadingMore = false;
@override
Future<wire.Paged<AlbumRef>> build() async {
final api = await ref.watch(_libraryListsApiProvider.future);
return api.listAlbums(limit: _pageSize, offset: 0);
}
Future<void> loadMore() async {
if (_loadingMore) return;
final cur = state.value;
if (cur == null) return;
if (cur.items.length >= cur.total) return;
_loadingMore = true;
try {
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listAlbums(
limit: _pageSize,
offset: cur.items.length,
);
state = AsyncData(wire.Paged(
items: [...cur.items, ...next.items],
total: next.total,
limit: next.limit,
offset: 0,
));
} catch (_) {
// Swallow — next scroll event will retry.
} finally {
_loadingMore = false;
}
}
}
final _libraryAlbumsProvider = AsyncNotifierProvider<
_LibraryAlbumsNotifier,
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
});
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listTracks();
});
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_likesApiProvider.future)).listAlbums();
});
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});
@@ -162,13 +360,46 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
// Pre-warm every tab's provider on Library mount so swiping
// between tabs feels instant rather than each tab paying its
// own cold-cache cost on first visit. ref.listen subscribes
// without rebuilding LibraryScreen on emit; subscriptions stay
// alive for the lifetime of this widget. cacheFirst handles
// dedupe of concurrent fetchAndPopulate triggers.
ref.listen(_libraryArtistsProvider, (_, __) {});
ref.listen(_libraryAlbumsProvider, (_, __) {});
ref.listen(_historyProvider, (_, __) {});
ref.listen(_likedTrackIdsProvider, (_, __) {});
ref.listen(_likedAlbumIdsProvider, (_, __) {});
ref.listen(_likedArtistIdsProvider, (_, __) {});
ref.listen(myQuarantineProvider, (_, __) {});
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Library', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/library')],
actions: [
IconButton(
key: const Key('shuffle_all_button'),
tooltip: 'Shuffle all',
icon: Icon(LucideIcons.shuffle, color: fs.parchment),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final refs = await ref.read(shuffleSourceProvider).tracks();
if (refs.isEmpty) {
messenger.showSnackBar(const SnackBar(
content: Text('Nothing to shuffle yet'),
));
return;
}
await ref
.read(playerActionsProvider)
.playTracks(refs, shuffle: true);
},
),
const MainAppBarActions(currentRoute: '/library'),
],
bottom: TabBar(
controller: _ctrl,
isScrollable: true,
@@ -208,51 +439,56 @@ 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) {
data: (artists) {
// Warm details for the first screenful so taps are instant.
ref
.read(metadataPrefetcherProvider)
.warmArtists(page.items.map((a) => a.id));
return page.items.isEmpty
.warmArtists(artists.map((a) => a.id));
return artists.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.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),
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
// LayoutBuilder + cell-aware ArtistCard width mirrors
// the AlbumsTab pattern so the circular avatar stays
// a true circle on narrow grid cells.
//
// Drift-first: GridView holds the full sorted list;
// .builder lazily realizes only visible cells, so
// even on large libraries the up-front cost is just
// a sort over cached_artists, not N network round
// trips.
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;
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
// + slack matched to AlbumsTab's overflow guard.
final cellH = (cellW - 16) + 8 + 18 + 8;
return GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemCount: artists.length,
itemBuilder: (ctx, i) {
final artist = page.items[i];
final artist = artists[i];
return ArtistCard(
artist: artist,
width: cellW,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
},
),
),
);
}),
);
},
);
@@ -268,17 +504,16 @@ 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) {
return page.items.isEmpty
data: (albums) {
return albums.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.invalidate(_libraryAlbumsProvider);
await ref.read(_libraryAlbumsProvider.future);
},
onRefresh: () async => ref.refresh(_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.
// Drift-first; full sorted list, lazy realization via
// GridView.builder.
child: LayoutBuilder(builder: (ctx, constraints) {
const cols = 3;
const sidePad = 8.0;
@@ -293,37 +528,26 @@ class _AlbumsTab extends ConsumerWidget {
// 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),
);
},
return GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: albums.length,
itemBuilder: (ctx, i) {
final album = albums[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
onTap: () => ctx.push('/albums/${album.id}',
extra: album),
);
},
);
}),
);
@@ -373,79 +597,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) {
final artist = ar.items[i];
return ArtistCard(
artist: artist,
onTap: () =>
ctx.push('/artists/${artist.id}', extra: artist),
);
},
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) {
final album = al.items[i];
return AlbumCard(
album: album,
onTap: () =>
ctx.push('/albums/${album.id}', extra: album),
);
},
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),
]),
@@ -453,13 +680,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
@@ -471,7 +787,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),
@@ -482,12 +798,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',
@@ -496,9 +812,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(
@@ -515,17 +847,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(LucideIcons.archive_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,11 +1,15 @@
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 {
class AlbumCard extends ConsumerWidget {
const AlbumCard({
required this.album,
required this.onTap,
@@ -32,7 +36,7 @@ class AlbumCard extends StatelessWidget {
final bool showArtist;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return SizedBox(
@@ -47,17 +51,29 @@ class AlbumCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
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),
),
// 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(
@@ -80,4 +96,17 @@ class AlbumCard extends StatelessWidget {
),
);
}
/// 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,20 +1,38 @@
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 {
const ArtistCard({required this.artist, required this.onTap, super.key});
class ArtistCard extends ConsumerWidget {
const ArtistCard({
required this.artist,
required this.onTap,
this.width = 140,
super.key,
});
final ArtistRef artist;
final VoidCallback onTap;
/// Outer width of the card. Avatar is a circle of width-16 (8dp
/// horizontal padding either side). Default suits horizontal lists;
/// grids should pass the cell width so the avatar shrinks
/// proportionally and stays a true circle.
final double width;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return SizedBox(
width: 140,
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -22,16 +40,36 @@ class ArtistCard extends StatelessWidget {
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),
),
// Stack: circular avatar + overlaid play button at bottom-right.
// Avatar size derives from the [width] parameter so the
// Library Artists 3-column grid can pass its cell width
// (~109dp on typical phones) and the avatar stays a
// perfect circle. Previous hardcoded 124×124 got squeezed
// horizontally in the grid (cell narrower than 124+16
// padding) but kept its 124dp height — ClipOval produced
// a visible vertical ellipse. Mirrors AlbumCard's width-
// parameter convention.
Stack(
children: [
ClipOval(
child: Container(
width: coverSize,
height: coverSize,
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(
@@ -46,4 +84,18 @@ class ArtistCard extends StatelessWidget {
),
);
}
/// 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);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../cache/audio_cache_manager.dart';
@@ -21,7 +22,7 @@ class CachedIndicator extends ConsumerWidget {
if (snap.data != true) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.download_done, size: 14, color: fs.accent),
child: Icon(LucideIcons.circle_check_big, size: 14, color: fs.accent),
);
},
);
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.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(
LucideIcons.play,
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,56 +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: 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: fs.parchment, fontSize: 14),
// 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),
),
// 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)
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
track.artistName,
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
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),
),
],
),
),
),
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),
]),
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),
]),
),
),
);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../shared/widgets/lucide_heart.dart';
import '../theme/theme_extension.dart';
import 'likes_provider.dart';
@@ -25,8 +26,8 @@ class LikeButton extends ConsumerWidget {
orElse: () => false,
);
return IconButton(
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
icon: LucideHeart(
filled: liked,
color: liked ? fs.accent : fs.ash,
size: size,
),
+13 -20
View File
@@ -7,6 +7,7 @@ import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart';
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
@@ -147,26 +148,18 @@ class LikesController {
} else {
await api.like(kind, id);
}
} catch (e, st) {
// Rollback drift
if (wasLiked) {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
} else {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
}
Error.throwWithStackTrace(e, st);
} catch (_) {
// REST failed (network or HTTP). Don't roll back drift — the
// user's intent is to like/unlike, and we want that visible to
// them even when offline. Queue the call for replay; the
// MutationReplayer will retry on next connectivity transition.
// If retries exhaust (5 attempts), the row drops and the next
// SyncController.sync brings drift back in line with the
// server's authoritative state.
await _ref.read(mutationQueueProvider).enqueue(
wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd,
{'kind': entityType, 'id': id},
);
}
}
+7
View File
@@ -14,6 +14,13 @@ Future<void> main() async {
androidNotificationChannelId: 'com.fabledsword.minstrel.audio',
androidNotificationChannelName: 'Minstrel playback',
androidNotificationOngoing: true,
// 8a: hand external surfaces (notification / lock screen / Wear)
// a downscaled cover instead of full-res album art — smaller
// payload, faster paint, lower memory. 300px is ample for those
// targets. preloadArtwork warms it so the first paint isn't blank.
artDownscaleWidth: 300,
artDownscaleHeight: 300,
preloadArtwork: true,
),
);
runApp(ProviderScope(
@@ -0,0 +1,51 @@
/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution —
/// one out-of-library artist from GET /api/discover/suggestions. image_url
/// is resolved on-demand from Lidarr server-side (may be empty).
class SeedContribution {
const SeedContribution({required this.name, required this.isLiked});
final String name;
final bool isLiked;
factory SeedContribution.fromJson(Map<String, dynamic> j) => SeedContribution(
name: j['name'] as String? ?? '',
isLiked: j['is_liked'] as bool? ?? false,
);
}
class ArtistSuggestion {
const ArtistSuggestion({
required this.mbid,
required this.name,
required this.imageUrl,
required this.attribution,
});
final String mbid;
final String name;
final String imageUrl;
final List<SeedContribution> attribution;
factory ArtistSuggestion.fromJson(Map<String, dynamic> j) => ArtistSuggestion(
mbid: j['mbid'] as String? ?? '',
name: j['name'] as String? ?? '',
imageUrl: j['image_url'] as String? ?? '',
attribution: ((j['attribution'] as List?) ?? const [])
.map((e) =>
SeedContribution.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
/// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3).
String get attributionText {
if (attribution.isEmpty) return '';
final phrases = attribution
.map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}')
.toList(growable: false);
if (phrases.length == 1) return 'Because you ${phrases[0]}.';
if (phrases.length == 2) {
return 'Because you ${phrases[0]} and ${phrases[1]}.';
}
return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.';
}
}
+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'),
);
}
}
+10
View File
@@ -38,6 +38,16 @@ class Playlist {
bool get isSystem => systemVariant != null;
/// Whether this playlist supports the generic by-kind refresh/
/// shuffle endpoints (#411 R2) — i.e. a singleton system kind.
/// The server exposes a `refreshable` flag for JSON-sourced
/// playlists, but the list tiles are drift-cache-sourced (no
/// migration just for this), so derive it: every system kind is a
/// singleton except songs_like_artist (multi-per-user). This rule
/// holds for For-You/Discover and all planned discovery mixes; if
/// a future non-singleton kind appears, extend the exclusion.
bool get refreshable => isSystem && systemVariant != 'songs_like_artist';
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
id: j['id'] as String,
userId: j['user_id'] as String? ?? '',
+15
View File
@@ -40,4 +40,19 @@ class TrackRef {
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
streamUrl: j['stream_url'] as String? ?? '',
);
/// Round-trips through [TrackRef.fromJson] (same server snake_case
/// keys). Used to persist the playback queue for resume-on-launch.
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'album_id': albumId,
'album_title': albumTitle,
'artist_id': artistId,
'artist_name': artistName,
if (trackNumber != null) 'track_number': trackNumber,
if (discNumber != null) 'disc_number': discNumber,
'duration_sec': durationSec,
'stream_url': streamUrl,
};
}
@@ -0,0 +1,92 @@
// 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;
}
/// Synchronous peek. Returns the previously-extracted color if this
/// album has been resolved this process; otherwise null. Used by
/// the now-playing fast-path swap so a warm cache transitions the
/// gradient in lockstep with the audio, instead of awaiting the
/// async [getOrExtract] future. A null return means "either no
/// extraction yet or extraction returned null" — caller falls back
/// to the async path.
Color? peekColor(String albumId) => _cache[albumId];
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);
},
);
@@ -27,6 +27,32 @@ class AlbumCoverCache {
/// same album dedupe to one fetch.
final Map<String, Future<String?>> _inflight = {};
/// Cached covers directory path resolved once on the first
/// async cacheDir call, then reused for sync existsSync() checks
/// in [peekCached]. Without this every "is the cover already on
/// disk?" check would have to await path_provider, blocking the
/// MediaItem broadcast on every track change.
String? _coversDirPath;
/// Returns the local file path for [albumId]'s cover if it's
/// already cached on disk, or null otherwise. Synchronous — uses
/// File.existsSync() against a path computed from the directory
/// resolved by an earlier async [getOrFetch] call. Returns null
/// until at least one getOrFetch has populated _coversDirPath.
///
/// The audio handler uses this to seed MediaItem.artUri on the
/// initial broadcast so external media controllers (Wear, Android
/// Auto, Bluetooth) see the cover on the first frame for warm-cache
/// tracks. Cold-cache tracks still fall back to the async
/// _loadArtForCurrentItem path.
String? peekCached(String albumId) {
if (albumId.isEmpty) return null;
final dir = _coversDirPath;
if (dir == null) return null;
final path = '$dir/$albumId.jpg';
return File(path).existsSync() ? path : null;
}
/// Returns local file path to the album cover, or null on any
/// failure (network, 4xx/5xx, disk full, empty albumId).
Future<String?> getOrFetch(String albumId) {
@@ -44,6 +70,9 @@ class AlbumCoverCache {
final dir = await _cacheDirFactory();
final coversDir = Directory('${dir.path}/album_covers');
await coversDir.create(recursive: true);
// Cache the resolved directory so [peekCached] can do its
// existsSync() check without re-awaiting path_provider.
_coversDirPath = coversDir.path;
final filePath = '${coversDir.path}/$albumId.jpg';
final file = File(filePath);
if (await file.exists()) return filePath;
+594 -46
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
@@ -15,11 +16,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
_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.
// failure, network drop) here. Logging alone leaves the UI
// claiming "now playing X" while audio is silent — the user-
// observable mismatch between visual and audio state. Skip
// forward so the queue advances past the failed track and
// mediaItem updates to whatever's actually playing. If the
// failure is at the queue tail, just_audio will go idle on its
// own and _broadcastState reflects that.
onError: (Object e, StackTrace st) {
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
unawaited(_handlePlaybackError());
},
);
_player.currentIndexStream.listen(_onCurrentIndexChanged);
@@ -33,15 +39,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
// the index and the eviction loop can't reclaim them.
_player.bufferedPositionStream
.listen((_) => unawaited(_maybeRegisterStreamCache()));
// Diagnostic: log every player-state transition so silent stops
// surface as something we can read in the log.
_player.playerStateStream.listen((s) {
debugPrint(
'audio_handler: state playing=${s.playing} processing=${s.processingState}');
});
_player.processingStateStream.listen((ps) {
debugPrint('audio_handler: processingState=$ps');
});
unawaited(_configureAudioSession());
}
final AudioPlayer _player = AudioPlayer();
@@ -49,6 +47,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
String? _token;
AlbumCoverCache? _coverCache;
AudioCacheManager? _audioCacheManager;
LikeBridge? _likeBridge;
/// Trackers to dedupe registration — once we've inserted an index row
/// for a trackId, don't repeat the work on every buffered-position
@@ -60,6 +59,85 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// time the buffered-position stream emits (~200ms cadence).
String? _cacheDirPath;
/// How long the session may sit not-actively-playing (paused, or a
/// finished queue) before we tear it down so the Wear tile / lock
/// screen / notification don't linger on a stale track. The
/// notification is configured ongoing (main.dart), so nothing else
/// ever drives the MediaSession to a terminal state.
static const _idleStopTimeout = Duration(minutes: 5);
/// Single-shot cleanup timer, armed while not actively playing and
/// cancelled the moment playback resumes or a new queue is set.
Timer? _idleStopTimer;
/// Periodic PlaybackState re-broadcast while actively playing so
/// external surfaces (lock screen, Wear, Android Auto) interpolate the
/// scrubber smoothly. The in-app bar uses positionStream and doesn't
/// need this. Active only while playing; cancelled otherwise.
Timer? _positionBroadcastTimer;
/// Player volume captured when an OS duck interruption begins,
/// restored when it ends. Null when not currently ducked.
double? _volumeBeforeDuck;
/// True when an interruption (call / other media) paused playback that
/// was actively going, so a transient (pause-type) interruption end
/// auto-resumes. Cleared on resume or a non-resumable end.
bool _interruptedWhilePlaying = false;
/// 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;
/// Logical-queue index that just_audio player-index 0 currently maps
/// to. setQueueFromTracks fast-starts with a SINGLE source at player
/// index 0 while queue.value already holds the full list, so the
/// playing track's logical index is clampedInitial, not 0. The
/// transient currentIndexStream→0 emission that arrives just after
/// setAudioSources resolves (and after _suppressIndexUpdates is back
/// to false) would otherwise make _onCurrentIndexChanged broadcast
/// queue.value[0] — the FIRST track — over the correct item, pinning
/// the mini bar / playlist marker to the wrong track until a later
/// index event. Decremented in lockstep with the backward fill's
/// front inserts so player-index → logical-index stays correct and
/// lands at 0 once the player list fully matches queue.value.
int _logicalIndexBase = 0;
/// Tracks the most recent setQueueFromTracks() input so
/// skipToQueueItem can reconstruct the source list. just_audio
/// requires every source to be built before it can be a skip
/// target, but setQueueFromTracks only builds the initial source
/// and fills the rest in the background — so a skip to an index
/// past the fill front needs to rebuild from the stored tracks.
List<TrackRef> _lastTracks = const [];
/// #415: which system playlist this queue was seeded from
/// ('for_you' | 'discover'), or null for library / user-playlist /
/// radio. The play-events reporter reads this so play_started
/// carries `source` and the server advances that rotation. A fresh
/// setQueueFromTracks from a non-system surface clears it; internal
/// rebuilds (skipToQueueItem) preserve it.
String? _queueSource;
String? get queueSource => _queueSource;
/// The full TrackRef list backing the current queue (even when only
/// part is built as just_audio sources). Empty when nothing is
/// queued. Exposed for resume-state persistence (#54).
List<TrackRef> get queuedTracks => _lastTracks;
/// Volume stream for UI subscribers. Mirrors the just_audio player's
/// volume directly; set via setVolume(double).
Stream<double> get volumeStream => _player.volumeStream;
@@ -73,48 +151,183 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
Stream<Duration> get positionStream => _player.positionStream;
Duration get position => _player.position;
/// Broadcasts the title of a track that just failed to play (404,
/// decoder failure, premature EOS, network drop) and was auto-skipped
/// or paused by _handlePlaybackError. The app listens and surfaces a
/// debounced/coalesced SnackBar so a silent skip isn't mysterious.
/// App-lifetime singleton handler — intentionally never closed.
final _playbackErrors = StreamController<String>.broadcast();
Stream<String> get playbackErrorStream => _playbackErrors.stream;
void configure({
required String baseUrl,
required String? token,
AlbumCoverCache? coverCache,
AudioCacheManager? audioCacheManager,
LikeBridge? likeBridge,
}) {
_baseUrl = baseUrl;
_token = token;
if (coverCache != null) _coverCache = coverCache;
if (likeBridge != null) _likeBridge = likeBridge;
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 {
final items = tracks.map(_toMediaItem).toList();
queue.add(items);
if (items.isNotEmpty) {
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
/// Invoked by play() when nothing is loaded (mediaItem == null) so a
/// media-button press after the #52 teardown resumes the last
/// persisted session (#448). Registered by ResumeController.start();
/// null until then (then it's a no-op fall-through to _player.play()).
Future<void> Function()? _resumeHook;
void setResumeHook(Future<void> Function() hook) => _resumeHook = hook;
Future<void> setQueueFromTracks(
List<TrackRef> tracks, {
int initialIndex = 0,
String? source,
}) async {
if (tracks.isEmpty) return;
// New playback supersedes any pending idle cleanup. play() below
// re-broadcasts and _reconcileIdleTimer would cancel anyway; doing
// it up front avoids a race during the pause→swap window.
_idleStopTimer?.cancel();
_idleStopTimer = null;
_queueSource = source;
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;
_lastTracks = tracks;
// Pause the old source immediately so the previous track stops
// audibly the moment the user taps, instead of bleeding through
// until the new source finishes building. setAudioSources below
// will swap the source list cleanly; pause is the simplest way
// to silence the player during the (possibly multi-100ms) build.
if (_player.playing) {
await _player.pause();
}
debugPrint('audio_handler.setQueueFromTracks: '
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
final sw = Stopwatch()..start();
final sources = await Future.wait(tracks.map(_buildAudioSource));
debugPrint('audio_handler: built ${sources.length} sources in '
'${sw.elapsedMilliseconds}ms');
sw.reset();
sw.start();
// Build MediaItems up front (pure — no side effects); we'll
// broadcast queue/mediaItem only AFTER setAudioSources resolves
// so the audio engine and the UI flip together. If the build
// throws, the UI stays on the previous track (correct: audio
// also stays on the previous track since setAudioSources never
// ran).
final items = tracks.map(_toMediaItem).toList();
await _player.setAudioSources(
sources,
initialIndex: initialIndex,
);
debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms');
// Build only the initial source for fast start. Remaining
// sources stream in via _fillRemainingSources() — addAudioSource
// for next/auto-advance tracks, insertAudioSource for skipPrev.
final AudioSource initial;
try {
initial = await _buildAudioSource(tracks[clampedInitial]);
} catch (e, st) {
// Source build failed (bad URL, missing baseUrl, etc.). Don't
// broadcast the new state — leaving queue/mediaItem on the
// previous track keeps UI in sync with what the player is
// actually doing (which is "still on the previous track,
// paused").
debugPrint('audio_handler: _buildAudioSource failed: $e\n$st');
return;
}
if (myGen != _queueGeneration) {
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
return;
}
// Suppress _onCurrentIndexChanged side effects while the source
// list is being swapped — without this, a transient currentIndex
// emission during setAudioSources could broadcast the OLD queue's
// entry at the NEW index. Re-enabled after the broadcasts land.
_suppressIndexUpdates = true;
try {
await _player.setAudioSources([initial], initialIndex: 0);
// Player-index 0 holds the single fast-start source, which is
// logical-queue index clampedInitial. Record the offset before
// broadcasting so the post-resolve currentIndexStream→0 emission
// maps back to the correct item instead of queue.value[0].
_logicalIndexBase = clampedInitial;
// Broadcast in this order: queue first (so any consumer that
// reacts to mediaItem and reads queue.value sees the consistent
// pair), then mediaItem.
queue.add(items);
mediaItem.add(items[clampedInitial]);
} finally {
_suppressIndexUpdates = false;
}
// 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));
}
/// Switches playback to the [index]th queue item. The full
/// just_audio source list isn't necessarily built yet
/// (_fillRemainingSources runs in the background), so we
/// reconstruct from the stored TrackRef list rather than calling
/// _player.seek(index: ...) on a possibly-missing source. Calling
/// setQueueFromTracks again is the safe path: it bumps the
/// generation, cancels any in-flight fill, rebuilds source[0] as
/// the target, and re-fills around. play() restarts playback so
/// queue taps feel like "jump to this song" rather than "set the
/// pointer and wait for me to press play."
@override
Future<void> skipToQueueItem(int index) async {
if (index < 0 || index >= _lastTracks.length) return;
// Preserve the system-playlist source across an internal rebuild
// — a queue-item skip is still playing from the same playlist.
await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource);
await play();
}
/// 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);
// Each front insert shifts the playing source's player index
// up by one; drop the base in lockstep so player-index →
// logical-index stays correct (and reaches 0 once the player
// list fully matches queue.value).
_logicalIndexBase -= 1;
}
} 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) {
@@ -143,7 +356,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));
}
}
@@ -169,15 +381,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
if (mgr != null) {
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
'using LockCachingAudioSource → ${cacheFile.path}');
// 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);
}
@@ -214,12 +423,32 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
final extras = <String, dynamic>{};
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
// Sync-peek the album cover cache so warm-cache tracks broadcast
// with artUri populated on the first frame. External media
// controllers (Android Wear, Bluetooth dashes, Auto, lock screen)
// can only render the cover bytes that audio_service hands them
// at MediaItem broadcast time; the later async _loadArtForCurrent
// Item path repopulates for cold-cache tracks. Without this seed,
// every track change starts with a generic icon on the watch and
// only gets the real cover after one or two seconds.
final coverPath = (t.albumId.isNotEmpty && _coverCache != null)
? _coverCache!.peekCached(t.albumId)
: null;
// MediaItem.rating intentionally NOT set: audio_service propagates
// it to MediaSession.setRating(), but the Android session also
// needs setRatingType(RATING_HEART) configured to expose that to
// controllers — audio_service doesn't surface that config knob,
// and broadcasting an unanchored rating made Wear OS reject the
// session entirely. The LikeBridge wiring stays in place so
// setRating can still fire from any surface that DOES route it,
// we just don't advertise it.
return MediaItem(
id: t.id,
title: t.title,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
artUri: coverPath != null ? Uri.file(coverPath) : null,
extras: extras.isEmpty ? null : extras,
);
}
@@ -253,20 +482,63 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
_streamCacheRegistered.add(trackId);
await mgr.registerStreamCache(trackId, path, size);
debugPrint(
'audio_handler: registered stream cache for $trackId ($size bytes)');
}
/// Called when playbackEventStream emits an error. Skips past the
/// failing track so the UI and audio re-converge — without this,
/// _player goes silent on a 404 / decoder failure / EOS but
/// mediaItem stays on the failed track and the user sees a "now
/// playing" header for something that isn't.
///
/// If we're at the last track, seekToNext is a no-op; the state
/// drops to idle and _broadcastState reflects that.
Future<void> _handlePlaybackError() async {
// mediaItem is the correctly-mapped current track (post #49 logical
// index), so it's the one that just failed — no player-index math.
final failed = mediaItem.value?.title;
if (failed != null && failed.isNotEmpty) {
_playbackErrors.add(failed);
}
final currentIdx = _player.currentIndex;
final queueLen = queue.value.length;
if (currentIdx == null || currentIdx + 1 >= queueLen) {
// Last track or no queue context — just pause; _broadcastState
// already reflects the idle/error processingState.
try {
await _player.pause();
} catch (_) {}
return;
}
try {
await _player.seekToNext();
} catch (_) {
// If seekToNext fails too (next source not built yet, etc.),
// there's nothing safe to do — pause and let the user recover
// manually.
try {
await _player.pause();
} catch (_) {}
}
}
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]);
// Map the just_audio player index back to the logical queue index.
// During the fast-start/fill window the player list is a moving
// window offset from queue.value by _logicalIndexBase; mapping the
// raw player index straight in here is what previously broadcast
// queue.value[0] (the first track) over the correct item on every
// fast-start with initialIndex > 0.
final logical = idx + _logicalIndexBase;
if (logical >= 0 && logical < items.length) {
mediaItem.add(items[logical]);
}
unawaited(_loadArtForCurrentItem());
}
@@ -290,8 +562,61 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
}
/// Drives the session to a terminal state and tears down the
/// foreground service so external surfaces (Wear tile, lock screen,
/// in-app mini bar) drop it instead of showing a stale paused track.
/// Called on the idle timeout and from onTaskRemoved when not playing;
/// no other path stops the session (the notification is ongoing).
@override
Future<void> play() => _player.play();
Future<void> stop() async {
_idleStopTimer?.cancel();
_idleStopTimer = null;
_positionBroadcastTimer?.cancel();
_positionBroadcastTimer = null;
try {
await _player.stop();
} catch (_) {}
// Explicit terminal broadcast before super.stop() tears down the
// isolate/notification, so subscribers see idle even if the
// player's own event lags the service teardown. queue/mediaItem
// cleared so the in-app mini bar collapses in lockstep with the
// watch tile rather than lingering on the last track.
playbackState.add(playbackState.value.copyWith(
playing: false,
processingState: AudioProcessingState.idle,
));
mediaItem.add(null);
queue.add([]);
await super.stop();
}
/// App swiped away from recents. Keep playing if audio is active
/// (standard media behaviour — music shouldn't die because the app
/// left recents); otherwise stop so the watch tile / notification
/// don't linger on a stale paused track.
@override
Future<void> onTaskRemoved() async {
if (_player.playing &&
_player.processingState != ProcessingState.completed) {
await super.onTaskRemoved();
return;
}
await stop();
}
@override
Future<void> play() async {
// Nothing loaded (fresh, or torn down by the #52 idle/dismiss
// teardown): a media-button press resumes the last persisted
// session instead of no-op'ing (#448). The hook restores the queue
// and starts playback itself, so we return without calling
// _player.play() on an empty player.
if (mediaItem.value == null && _resumeHook != null) {
await _resumeHook!();
return;
}
await _player.play();
}
@override
Future<void> pause() => _player.pause();
@@ -305,6 +630,51 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
/// Heart rating from external surfaces (Wear's favorite button,
/// lock-screen like) → LikesController.toggle(track). We only
/// route through the bridge when the rating actually flips relative
/// to the current state, so repeated taps from a flaky controller
/// don't ping-pong the like.
@override
Future<void> setRating(Rating rating, [Map<String, dynamic>? extras]) async {
final media = mediaItem.value;
final bridge = _likeBridge;
if (media == null || bridge == null) return;
final currentlyLiked = bridge.isTrackLiked(media.id);
final wantLiked = rating.hasHeart();
if (currentlyLiked == wantLiked) return;
try {
await bridge.toggleTrackLike(media.id);
} catch (_) {
// LikesController already rolls back on REST failure; nothing
// to do here beyond letting the broadcast skip.
return;
}
// Re-emit so the watch's heart icon updates immediately.
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked)));
}
/// Re-emits the current mediaItem with a fresh rating pulled from
/// the LikeBridge. Called by PlayerActions on likedIdsProvider
/// changes so the watch / lock-screen heart icon updates when the
/// user toggles a like from TrackRow, the kebab menu, or another
/// device's playback (SSE-routed). No-op if no track is playing
/// or the like state didn't change.
void refreshCurrentRating() {
final media = mediaItem.value;
final bridge = _likeBridge;
if (media == null || bridge == null) return;
final liked = bridge.isTrackLiked(media.id);
if (media.rating?.hasHeart() == liked) return;
mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked)));
}
/// Re-broadcasts PlaybackState so the notification favorite control's
/// icon/label reflects a like toggled from elsewhere (TrackRow, kebab,
/// another device via SSE). Sibling to refreshCurrentRating, which
/// updates the Wear/lock heart via MediaItem.rating.
void refreshFavoriteControl() => _broadcastState(null);
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
await _player
@@ -329,11 +699,38 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
await _player.setVolume(v.clamp(0.0, 1.0));
}
/// Handles the notification favorite control (and any future custom
/// actions). Toggles the current track's like via the LikeBridge,
/// then re-broadcasts so the heart icon/label flips. Signature
/// matches `AudioHandler.customAction` (`Future<dynamic>`).
@override
Future<dynamic> customAction(String name,
[Map<String, dynamic>? extras]) async {
if (name == 'minstrel.favorite') {
final media = mediaItem.value;
final bridge = _likeBridge;
if (media == null || bridge == null) return null;
try {
await bridge.toggleTrackLike(media.id);
} catch (_) {}
_broadcastState(null);
return null;
}
return super.customAction(name, extras);
}
// _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;
// No custom favorite MediaControl here. audio_service builds a
// PlaybackStateCompat.CustomAction for it and throws
// "You must specify an icon resource id to build a CustomAction"
// on real builds (the androidIcon doesn't resolve to a usable id),
// and that exception aborts the ENTIRE media notification — no
// tray or Wear controls at all. Removed; like/favorite remains
// available in-app and via the standard lock-screen surface.
playbackState.add(PlaybackState(
controls: [
MediaControl.skipToPrevious,
@@ -347,6 +744,18 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
// 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+.
//
// v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode,
// setRepeatMode, and setRating to this set; reverted because
// Pixel Watch 2 stopped showing controls entirely after that
// change. The MediaSession contract on Wear OS requires more
// setup than just advertising the action (e.g. setRatingType
// for setRating) and audio_service doesn't expose those knobs.
// Keep the override methods themselves (skipToQueueItem is
// still routed via QueueScreen's direct handler call;
// setRating is harmless if never invoked) so we don't lose
// the underlying functionality — just don't tell the system
// we support them.
systemActions: const {
MediaAction.play,
MediaAction.pause,
@@ -375,5 +784,144 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
LoopMode.all => AudioServiceRepeatMode.all,
},
));
_reconcileIdleTimer();
_reconcilePositionBroadcast();
}
/// Arms (or cancels) the idle-cleanup timer based on the current
/// player state. Driven from _broadcastState, which fires on every
/// play/pause/buffer/complete transition, so paused AND finished-queue
/// both arm it. Idle (already stopped) never re-arms — otherwise stop()
/// would loop every _idleStopTimeout.
void _reconcileIdleTimer() {
final ps = _player.processingState;
if (ps == ProcessingState.idle) {
_idleStopTimer?.cancel();
_idleStopTimer = null;
return;
}
final activelyPlaying =
_player.playing && ps != ProcessingState.completed;
if (activelyPlaying) {
_idleStopTimer?.cancel();
_idleStopTimer = null;
return;
}
_idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout);
}
void _onIdleTimeout() {
_idleStopTimer = null;
final ps = _player.processingState;
if (ps == ProcessingState.idle) return; // already torn down
if (_player.playing && ps != ProcessingState.completed) {
return; // resumed between arm and fire
}
unawaited(stop());
}
/// While actively playing, keeps a 1s periodic PlaybackState re-
/// broadcast running so updateTime/updatePosition stay fresh and
/// external surfaces interpolate the scrubber smoothly. Idempotent and
/// driven from _broadcastState — which the periodic tick itself calls,
/// so the "already running" guard prevents pile-up. Cancels the moment
/// playback is no longer active.
void _reconcilePositionBroadcast() {
final ps = _player.processingState;
final active = _player.playing &&
ps != ProcessingState.idle &&
ps != ProcessingState.completed;
if (!active) {
_positionBroadcastTimer?.cancel();
_positionBroadcastTimer = null;
return;
}
_positionBroadcastTimer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => _broadcastState(null),
);
}
/// Configures the OS audio session for music playback and wires
/// interruption + becoming-noisy handling. just_audio auto-activates /
/// deactivates the session around playback once it's configured, but
/// does NOT handle interruptions or the headphones-unplugged event
/// itself — that's done here. Best effort: a failure must not break
/// playback.
Future<void> _configureAudioSession() async {
try {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
session.interruptionEventStream.listen(_onInterruption);
session.becomingNoisyEventStream.listen((_) {
// Headphones unplugged / Bluetooth disconnected — never blast the
// phone speaker; pause like every other media app.
unawaited(_player.pause());
});
} catch (e, st) {
debugPrint('audio_handler: audio session configure failed: $e\n$st');
}
}
void _onInterruption(AudioInterruptionEvent event) {
if (event.begin) {
switch (event.type) {
case AudioInterruptionType.duck:
// Transient: another app wants the foreground briefly. Lower
// our volume rather than stopping (OS may also auto-duck).
_volumeBeforeDuck = _player.volume;
unawaited(_player.setVolume(0.3));
case AudioInterruptionType.pause:
case AudioInterruptionType.unknown:
// Call / other media took focus. Remember whether we were
// actively playing so a transient end can resume us.
_interruptedWhilePlaying = _player.playing &&
_player.processingState != ProcessingState.completed;
unawaited(_player.pause());
}
} else {
switch (event.type) {
case AudioInterruptionType.duck:
unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0));
_volumeBeforeDuck = null;
case AudioInterruptionType.pause:
// Transient interruption ended — resume only if WE paused it
// and the session is still alive (a long call may have let the
// #52 idle timer tear it down; re-init is resume-last-session
// territory, out of scope here).
if (_interruptedWhilePlaying &&
_player.processingState != ProcessingState.idle) {
unawaited(_player.play());
}
_interruptedWhilePlaying = false;
case AudioInterruptionType.unknown:
// Unknown end — do not auto-resume (could surprise the user).
_interruptedWhilePlaying = false;
}
}
}
}
/// Adapter that lets the audio handler call into the app's
/// LikesController without depending on Riverpod directly. Constructed
/// in PlayerActions where ref is available; consumed inside the audio
/// handler's setRating override and MediaItem builder to keep external
/// media controllers (Wear, lock screen, Auto) in sync with the
/// likedIds drift cache.
class LikeBridge {
const LikeBridge({
required this.toggleTrackLike,
required this.isTrackLiked,
});
/// Flip the like state for [trackId]. Returns a Future that resolves
/// once the underlying LikesController has both updated drift
/// optimistically and rolled the change through the REST API
/// (errors result in drift rollback inside LikesController).
final Future<void> Function(String trackId) toggleTrackLike;
/// Read the current like state for [trackId] from the drift-backed
/// likedIdsProvider. Synchronous because the audio handler needs
/// to populate MediaItem.rating on the broadcast hot path.
final bool Function(String trackId) isTrackLiked;
}
+286 -35
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -11,8 +12,20 @@ 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';
/// 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:
///
@@ -33,6 +46,133 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// dismiss. Reset on each drag start.
double _dragOffset = 0;
/// The MediaItem currently displayed on the screen. Held separately
/// from the live mediaItemProvider so we can preload the new track's
/// cover bytes + dominant color BEFORE flipping the visible state.
/// Without this gate the full-player UI swapped to the new track's
/// title/cover slot immediately while the image was still decoding,
/// producing a visible "pop in" once the bytes arrived.
MediaItem? _displayedMedia;
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
/// backdrop AnimatedContainer when it changes.
Color? _displayedDominant;
/// The id of the most-recent track we kicked a preload for. Used to
/// drop stale preload completions when the user skips rapidly past
/// a track whose cover hadn't finished decoding yet.
String? _pendingPreloadId;
@override
void initState() {
super.initState();
// Seed displayed state from the current mediaItem if a track is
// already playing when the full player is opened. ref.listen
// below only fires on CHANGES after subscription, so without
// this seed the screen sits on "Nothing playing." even though
// the mini bar shows a live track — the listener never gets a
// mediaItem emission because the stream's current value hasn't
// changed.
final current = ref.read(mediaItemProvider).value;
if (current == null) return;
_displayedMedia = current;
// Best-effort synchronous color seed: if albumColorProvider has
// already extracted this album's dominant color earlier in the
// session, pull it now so the backdrop renders correctly on
// first frame. Otherwise the post-frame _scheduleSwap below
// resolves it asynchronously and AnimatedContainer tweens.
final albumId = current.extras?['album_id'] as String?;
if (albumId != null && albumId.isNotEmpty) {
_displayedDominant =
ref.read(albumColorProvider(albumId)).asData?.value;
}
// Kick the preload pipeline post-frame so the cover bytes are
// decoded + the dominant color is resolved even when the user
// opens the player to a track that hasn't yet flowed through
// ref.listen.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scheduleSwap(current);
});
}
/// Preload the new track's cover bytes + dominant color, then
/// atomically flip _displayedMedia / _displayedDominant. Until both
/// resolve, the screen continues to render the previous track —
/// the new title/cover/gradient appear together in one frame.
///
/// Concurrency: if a second track change arrives while the first
/// preload is still in flight, the older preload's
/// _pendingPreloadId check fails and its completion is dropped.
Future<void> _scheduleSwap(MediaItem newMedia) async {
_pendingPreloadId = newMedia.id;
// Fast path: when Prefetcher has already warmed both the cover
// bytes (file:// artUri populated via _toMediaItem's peekCached)
// AND the palette color (AlbumColorCache.peekColor returns
// non-null), we can commit synchronously. The slow async path
// exists for genuine cold-cache moments (first play of an album
// that sync hasn't seen yet); the fast path is what makes the
// common in-queue auto-advance flip the visual in lockstep with
// the audio transition instead of lagging a tick behind.
final artUri = newMedia.artUri;
final albumId = newMedia.extras?['album_id'] as String?;
if (artUri != null &&
artUri.isScheme('file') &&
albumId != null &&
albumId.isNotEmpty) {
final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
if (cachedColor != null) {
if (!mounted) return;
if (_pendingPreloadId != newMedia.id) return;
setState(() {
_displayedMedia = newMedia;
_displayedDominant = cachedColor;
});
return;
}
}
// Slow path: cover and/or color are not yet cached. Hold the
// current displayed state, preload, then atomic-commit.
//
// 1. Precache the cover image bytes so when _AlbumArt mounts with
// the new media, FileImage paints synchronously. Non-file
// artUris fall through to ServerImage which handles its own
// network load + 120ms fade — they won't snap.
if (artUri != null && artUri.isScheme('file') && context.mounted) {
try {
await precacheImage(FileImage(File.fromUri(artUri)), context);
} catch (_) {
// Decode failed (missing file, corrupt bytes) — proceed
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
}
}
// 2. Wait for the dominant-color extraction so the gradient is
// populated when we swap. PaletteGenerator runs against the
// same FileImage, typically resolving within ~50ms once the
// decode completes.
Color? newDominant;
if (albumId != null && albumId.isNotEmpty) {
try {
newDominant = await ref.read(albumColorProvider(albumId).future);
} catch (_) {
// Color extraction failed — keep the previous dominant so the
// backdrop doesn't drop to obsidian on the swap.
}
}
// 3. Atomic flip. Bail if a newer swap superseded us, or the
// screen was disposed mid-preload.
if (!mounted) return;
if (_pendingPreloadId != newMedia.id) return;
setState(() {
_displayedMedia = newMedia;
if (newDominant != null) _displayedDominant = newDominant;
});
}
void _onDragStart(DragStartDetails _) {
_dragOffset = 0;
}
@@ -55,10 +195,69 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) {
// Listen for mediaItem changes and schedule a preload-then-swap.
// Build renders _displayedMedia / _displayedDominant; the live
// mediaItemProvider only drives the swap pipeline.
//
// Decision process:
// - On the first non-null mediaItem after mount, seed
// _displayedMedia immediately so the screen has something to
// paint.
// - On a track id change, kick off a preload. The new track's
// cover bytes + dominant color must both resolve before we
// flip the displayed state. The user sees the previous track
// in full until the new one is fully ready, then a clean
// atomic swap (no fade, no placeholder flash).
// - On the same track id but the artUri-bearing rebroadcast
// (audio_handler sends MediaItem twice on track change),
// refresh through the same preload pipeline so the displayed
// cover reflects the freshly-written AlbumCoverCache file.
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
final newMedia = next.asData?.value;
if (newMedia == null) {
if (_displayedMedia != null) {
setState(() {
_displayedMedia = null;
_displayedDominant = null;
_pendingPreloadId = null;
});
// Session was torn down (#52 idle/dismiss) while the full
// player was open. Don't strand the user on an empty
// "Nothing playing." screen — minimize back (the mini bar is
// already gone too). maybePop is a no-op if this is somehow
// the root route.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
}
return;
}
if (_displayedMedia == null) {
// First mount with a track playing — seed immediately, then
// kick the preload pipeline to update the dominant color and
// ensure the cover is decoded.
setState(() => _displayedMedia = newMedia);
_scheduleSwap(newMedia);
return;
}
final isNewTrack = newMedia.id != _displayedMedia!.id;
final coverGotPopulated =
newMedia.id == _displayedMedia!.id &&
newMedia.artUri != null &&
_displayedMedia!.artUri == null;
if (isNewTrack || coverGotPopulated) {
_scheduleSwap(newMedia);
}
});
final playback = ref.watch(playbackStateProvider).value;
final displayedMedia = _displayedMedia;
if (displayedMedia == null) {
// Either nothing playing, or the very first mount before a
// mediaItem has been emitted. The ref.listen above will seed
// _displayedMedia as soon as a non-null MediaItem arrives.
return const Scaffold(body: Center(child: Text('Nothing playing.')));
}
@@ -67,12 +266,21 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// 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 dur = displayedMedia.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?) ?? '';
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
// Backdrop color: render the dominant we've already committed to
// _displayedDominant. AnimatedContainer tweens between successive
// values, so the only color changes the user sees are the
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
// the gradient present without overwhelming the title/artist
// text below.
final dominant = _displayedDominant ?? fs.obsidian;
final gradientTop = dominant.withValues(alpha: 0.55);
return Scaffold(
backgroundColor: fs.obsidian,
@@ -84,7 +292,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
behavior: HitTestBehavior.translucent,
child: SafeArea(
child: AnimatedContainer(
duration: _trackChangeDuration,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [gradientTop, fs.obsidian],
stops: const [0.0, 0.7],
),
),
child: SafeArea(
child: Column(
children: [
_TopBar(fs: fs),
@@ -94,32 +312,44 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
child: Column(
children: [
const Spacer(),
_AlbumArt(media: media, albumId: albumId, fs: fs),
// No AnimatedSwitcher: _displayedMedia only
// advances after the preload pipeline has the
// new cover + color ready, so a track change
// is an atomic swap. Fading would just smear a
// transition the user explicitly asked us not
// to add.
_AlbumArt(
media: displayedMedia, albumId: albumId, fs: fs),
const SizedBox(height: 28),
_TitleRow(media: media, fs: fs),
_TitleRow(media: displayedMedia, fs: fs),
const SizedBox(height: 4),
Text(
media.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayedMedia.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if ((displayedMedia.album ?? '').isNotEmpty) ...[
const SizedBox(height: 2),
Text(
displayedMedia.album!,
style: TextStyle(color: fs.ash, fontSize: 12),
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,
media: displayedMedia,
),
const SizedBox(height: 8),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
@@ -136,6 +366,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
],
),
),
),
),
);
@@ -154,13 +385,13 @@ class _TopBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(children: [
IconButton(
icon: Icon(Icons.expand_more, color: fs.parchment),
icon: Icon(LucideIcons.chevron_down, color: fs.parchment),
tooltip: 'Close',
onPressed: () => Navigator.of(context).maybePop(),
),
const Spacer(),
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
icon: Icon(LucideIcons.list_music, color: fs.parchment),
tooltip: 'Queue',
onPressed: () => GoRouter.of(context).push('/queue'),
),
@@ -204,9 +435,18 @@ class _AlbumArt extends StatelessWidget {
aspectRatio: 1,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: cover,
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,
),
),
),
);
@@ -304,13 +544,13 @@ class _PrimaryControls extends StatelessWidget {
children: [
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_previous, color: fs.parchment),
icon: Icon(LucideIcons.skip_back, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
IconButton(
iconSize: 72,
icon: Icon(
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
color: fs.accent,
),
onPressed: () {
@@ -324,7 +564,7 @@ class _PrimaryControls extends StatelessWidget {
),
IconButton(
iconSize: 36,
icon: Icon(Icons.skip_next, color: fs.parchment),
icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
],
@@ -358,7 +598,7 @@ class _SecondaryControls extends StatelessWidget {
IconButton(
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
icon: Icon(
Icons.shuffle,
LucideIcons.shuffle,
color: shuffleOn ? fs.accent : fs.ash,
),
onPressed: actions.toggleShuffle,
@@ -371,8 +611,8 @@ class _SecondaryControls extends StatelessWidget {
},
icon: Icon(
repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
? LucideIcons.repeat_1
: LucideIcons.repeat,
color: repeatMode == AudioServiceRepeatMode.none
? fs.ash
: fs.accent,
@@ -381,7 +621,7 @@ class _SecondaryControls extends StatelessWidget {
),
IconButton(
tooltip: 'Queue',
icon: Icon(Icons.queue_music, color: fs.ash),
icon: Icon(LucideIcons.list_music, color: fs.ash),
onPressed: () => GoRouter.of(context).push('/queue'),
),
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
@@ -397,6 +637,17 @@ class _SecondaryControls extends StatelessWidget {
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);
},
),
],
);
@@ -0,0 +1,277 @@
// Flutter play-event lifecycle reporter (#415 stage 3).
//
// The Flutter client previously reported NO plays — listening on
// mobile never reached the server's play_events, so history,
// recommendation scoring, ListenBrainz scrobbles, and (since #415)
// system-playlist rotation all missed mobile activity entirely. This
// closes that gap and is the path that carries the #415 `source` tag.
//
// State machine over (current track id, playing). Mirrors the web
// events dispatcher, with one deliberate divergence: when a track
// changes inside a queue we classify ended-vs-skipped by whether the
// prior track reached (near) its duration, instead of the web
// dispatcher's blanket "track change = skip". Blanket-skip would mark
// every naturally-finished in-queue track as a skip and dilute the
// recommendation skip-ratio — the exact failure mode that motivated
// doing this properly. (The web dispatcher likely has the same
// false-skip issue; flagged separately, not fixed here.)
import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/events.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
import '../cache/mutation_queue.dart'
show MutationKinds, mutationQueueProvider;
import '../library/library_providers.dart' show dioProvider;
import 'audio_handler.dart' show MinstrelAudioHandler;
import 'player_provider.dart' show audioHandlerProvider;
const _clientIdKey = 'play_events_client_id';
/// Tolerance for "the track basically finished": within 3s of the
/// known duration counts as a natural completion, not a skip.
const _completionToleranceMs = 3000;
class PlayEventsReporter with WidgetsBindingObserver {
PlayEventsReporter(this._ref);
final Ref _ref;
final _subs = <StreamSubscription<dynamic>>[];
bool _disposed = false;
String? _clientId;
EventsApi? _api;
// Server play_event_id when the live play_started succeeded; null
// if start failed / offline — then the close is captured into the
// offline mutation queue instead of a live ended/skipped call.
String? _openPlayEventId;
// The play currently being tracked, captured independently of
// connectivity so an offline play is still a complete record.
String? _curTrackId;
DateTime? _curStartedAt;
String? _curSource;
int _curLastPositionMs = 0;
int _curDurationMs = 0;
bool _curReachedEnd = false;
String? _prevTrackId;
Future<void> start() async {
final MinstrelAudioHandler handler;
try {
// audioHandlerProvider throws until main() overrides it (real
// app always does). In tests / no-audio environments there's
// nothing to report — fail safe and stay inert rather than
// surfacing an unhandled async error.
handler = _ref.read(audioHandlerProvider);
_clientId = await _resolveClientId();
final dio = await _ref.read(dioProvider.future);
_api = EventsApi(dio);
} catch (_) {
return;
}
if (_disposed) return;
WidgetsBinding.instance.addObserver(this);
_subs.add(handler.positionStream.listen((p) {
final ms = p.inMilliseconds;
// Advance the tracked play's progress ONLY while its track is
// the current one. A track change resets position to 0; gating
// on _curTrackId keeps the finishing track's last-known values
// intact for the close branch.
final mi = handler.mediaItem.value;
if (mi != null && mi.id == _curTrackId) {
_curLastPositionMs = ms;
final d = mi.duration;
if (d != null && d.inMilliseconds > 0) {
_curDurationMs = d.inMilliseconds;
if (ms >= _curDurationMs - _completionToleranceMs) {
_curReachedEnd = true;
}
}
}
}));
_subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
_subs.add(handler.playbackState.listen((_) => _evaluate(handler)));
}
void _evaluate(MinstrelAudioHandler handler) {
if (_disposed) return;
final mi = handler.mediaItem.value;
final st = handler.playbackState.value;
final tid = mi?.id;
final playing = st.playing;
final completed = st.processingState == AudioProcessingState.completed;
// Track changed → close the prior tracked play.
if (tid != _prevTrackId && _curTrackId != null) {
_closeCurrent(viaOffline: false);
}
// Entered playing for a new track → begin tracking it.
if (tid != null && playing && _curTrackId != tid) {
_beginTrack(handler, tid);
}
// Whole-queue natural end (just_audio only emits `completed` at
// the end of the sequence, not between items) → close it.
if (completed && _curTrackId != null) {
_curReachedEnd = true;
_closeCurrent(viaOffline: false);
}
_prevTrackId = tid;
}
void _beginTrack(MinstrelAudioHandler handler, String trackId) {
_curTrackId = trackId;
_curStartedAt = DateTime.now().toUtc();
_curSource = handler.queueSource;
_curLastPositionMs = 0;
_curReachedEnd = false;
final d = handler.mediaItem.value?.duration;
_curDurationMs = d?.inMilliseconds ?? 0;
_openPlayEventId = null;
// Fire the live play_started; adopt the server id only if we're
// still on this track when the response lands. Failure is fine —
// the close path captures the whole play into the offline queue.
_startLive(handler, trackId);
}
Future<void> _startLive(MinstrelAudioHandler handler, String trackId) async {
final api = _api;
final cid = _clientId;
if (api == null || cid == null) return;
try {
final id = await api.playStarted(
trackId: trackId,
clientId: cid,
source: _curSource,
);
if (id != null && _curTrackId == trackId) {
_openPlayEventId = id;
}
} catch (_) {
// Offline / flaky — _openPlayEventId stays null; the close path
// enqueues the completed play for replay.
}
}
/// Closes the currently-tracked play. `finished` is derived from
/// whether it reached ~its duration. If the live start registered a
/// server id we attempt the live ended/skipped close and fall back
/// to the offline queue on failure; with no server id (offline
/// start) — or viaOffline (app teardown, must be durable) — the
/// completed play is enqueued directly. The server's RecordOffline
/// Play applies the canonical skip rule, so the offline payload
/// only needs duration, not our finished/skipped guess.
void _closeCurrent({required bool viaOffline}) {
final trackId = _curTrackId;
final startedAt = _curStartedAt;
if (trackId == null || startedAt == null) {
_resetCurrent();
return;
}
final reached = _curReachedEnd;
final lastPos = _curLastPositionMs;
final durationMs = (reached && _curDurationMs > 0)
? _curDurationMs
: lastPos;
final source = _curSource;
final id = _openPlayEventId;
if (!viaOffline && id != null) {
// Live close; on failure, fall back to the durable offline path
// so a transient blip at close time doesn't lose the play.
final fut = reached
? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs)
: _api?.playSkipped(playEventId: id, positionMs: lastPos);
fut?.catchError((_) {
_enqueueOffline(trackId, startedAt, source, durationMs);
});
} else {
_enqueueOffline(trackId, startedAt, source, durationMs);
}
_resetCurrent();
}
void _resetCurrent() {
_curTrackId = null;
_curStartedAt = null;
_curSource = null;
_curLastPositionMs = 0;
_curDurationMs = 0;
_curReachedEnd = false;
_openPlayEventId = null;
}
void _enqueueOffline(
String trackId,
DateTime startedAt,
String? source,
int durationPlayedMs,
) {
final cid = _clientId;
if (cid == null) return;
// ignore: unawaited_futures
_ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, {
'trackId': trackId,
'clientId': cid,
'at': startedAt.toIso8601String(),
'durationPlayedMs': durationPlayedMs,
if (source != null && source.isNotEmpty) 'source': source,
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// App backgrounded / killed mid-play: close durably via the
// offline queue (a fire-and-forget POST during teardown is
// unreliable; the queue survives a process kill and drains on
// next launch). Mirrors the intent of web's pagehide beacon.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
if (_curTrackId != null) {
_closeCurrent(viaOffline: true);
}
}
}
Future<String> _resolveClientId() async {
final storage = _ref.read(secureStorageProvider);
final existing = await storage.read(key: _clientIdKey);
if (existing != null && existing.isNotEmpty) return existing;
final rnd = Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
final id =
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _clientIdKey, value: id);
return id;
}
void dispose() {
_disposed = true;
WidgetsBinding.instance.removeObserver(this);
for (final s in _subs) {
s.cancel();
}
_subs.clear();
}
}
/// Read once at app start (app.dart postFrame) to activate reporting.
/// Disposed via ref.onDispose when the scope tears down.
final playEventsReporterProvider = Provider<PlayEventsReporter>((ref) {
final r = PlayEventsReporter(ref);
ref.onDispose(r.dispose);
// ignore: unawaited_futures
r.start();
return r;
});
@@ -0,0 +1,78 @@
// Surfaces playback errors (#58). _handlePlaybackError in the audio
// handler silently skips a dead track (404 / decoder failure / premature
// EOS / network drop) with only a debugPrint — which hides exactly the
// signal that distinguishes "this track is broken" from "the app is
// flaky" (server file moved, auth expired, cache miss, transcode fail).
//
// This listens to the handler's playbackErrorStream and shows a
// transient SnackBar via a global ScaffoldMessenger key. Bursts are
// coalesced: a debounce window collects errors and emits one message
// ("Skipped N unplayable tracks") instead of stacking N toasts.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'player_provider.dart';
/// Set on MaterialApp.router so SnackBars can be shown from outside any
/// widget's BuildContext (the handler's error stream is isolate-side).
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class PlaybackErrorReporter {
PlaybackErrorReporter(this._ref);
final Ref _ref;
StreamSubscription<String>? _sub;
Timer? _debounce;
final _buffer = <String>[];
bool _disposed = false;
void start() {
try {
// audioHandlerProvider throws until main() overrides it (the real
// app always does). In tests / no-audio environments there's
// nothing to report — stay inert.
final h = _ref.read(audioHandlerProvider);
_sub = h.playbackErrorStream.listen(_onError);
} catch (_) {
return;
}
}
void _onError(String title) {
if (_disposed) return;
_buffer.add(title);
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 2), _flush);
}
void _flush() {
if (_disposed || _buffer.isEmpty) return;
final n = _buffer.length;
final first = _buffer.first;
_buffer.clear();
final msg = n == 1
? 'Couldnt play “$first” — skipping'
: 'Skipped $n unplayable tracks';
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(content: Text(msg)),
);
}
void dispose() {
_disposed = true;
_debounce?.cancel();
_sub?.cancel();
}
}
/// Read once at app start (app.dart postFrame). Disposed via
/// ref.onDispose when the scope tears down.
final playbackErrorReporterProvider = Provider<PlaybackErrorReporter>((ref) {
final r = PlaybackErrorReporter(ref);
ref.onDispose(r.dispose);
r.start();
return r;
});
+74 -23
View File
@@ -1,7 +1,9 @@
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_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -10,6 +12,7 @@ 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
@@ -26,16 +29,37 @@ import 'player_provider.dart';
/// 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 {
class PlayerBar extends ConsumerStatefulWidget {
const PlayerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PlayerBar> createState() => _PlayerBarState();
}
class _PlayerBarState extends ConsumerState<PlayerBar> {
/// Last non-null artUri we've seen from the mediaItem stream. Held
/// across the audio_handler's two-broadcast track-change pattern
/// (bare MediaItem first, then with artUri once AlbumCoverCache
/// resolves) so the mini bar keeps showing the previous track's
/// cover until the new one is known. Without this hold the bar
/// flickered to the slate placeholder for ~100ms on every track
/// change.
Uri? _lastArtUri;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) return const SizedBox.shrink();
// Capture the latest non-null artUri so _TrackInfo's cover stays
// continuous across track changes.
if (media.artUri != null) _lastArtUri = media.artUri;
final displayMedia = media.artUri == null && _lastArtUri != null
? media.copyWith(artUri: _lastArtUri)
: media;
// 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.
@@ -63,7 +87,7 @@ class PlayerBar extends ConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _TrackInfo(media: media)),
Expanded(child: _TrackInfo(media: displayMedia)),
const SizedBox(width: 8),
_PlayControls(playback: playback, ref: ref),
],
@@ -88,29 +112,56 @@ class _TrackInfo extends StatelessWidget {
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.
//
// Why no transition / placeholder handling: the audio_handler
// broadcasts MediaItem twice on track change — once with artUri
// null (the new track's bare metadata), then with artUri set once
// AlbumCoverCache resolves. The widget tree above hands us the
// most-recent non-null artUri (`displayArtUri`), so the previous
// track's cover stays visible across that null gap and the new
// cover snaps in the moment its artUri arrives. Rapid change is
// fine here per operator preference; AnimatedSwitcher previously
// smeared the swap and made the slate placeholder visible.
final displayArtUri = media.artUri;
final Widget cover;
if (displayArtUri != 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: displayArtUri.isScheme('file')
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
: CachedNetworkImageProvider(displayArtUri.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: [
if (media.artUri != null)
Image(
image: media.artUri!.isScheme('file')
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
: NetworkImage(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
Container(width: 48, height: 48, color: fs.slate),
Hero(
tag: kPlayerCoverHeroTag,
child: cover,
),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -179,7 +230,7 @@ class _PlayControls extends StatelessWidget {
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 22,
icon: Icon(Icons.skip_previous, color: fs.parchment),
icon: Icon(LucideIcons.skip_back, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
),
),
@@ -190,7 +241,7 @@ class _PlayControls extends StatelessWidget {
padding: EdgeInsets.zero,
iconSize: 32,
icon: Icon(
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
color: fs.accent,
),
onPressed: () {
@@ -209,7 +260,7 @@ class _PlayControls extends StatelessWidget {
child: IconButton(
padding: EdgeInsets.zero,
iconSize: 22,
icon: Icon(Icons.skip_next, color: fs.parchment),
icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
),
),
+83 -20
View File
@@ -1,11 +1,14 @@
import 'dart:math' show Random;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart' show LikeKind;
import '../api/endpoints/radio.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart';
import '../library/library_providers.dart' show dioProvider;
import '../likes/likes_provider.dart';
import '../models/track.dart';
import 'album_cover_cache.dart';
import 'audio_handler.dart';
@@ -50,26 +53,39 @@ final positionProvider = StreamProvider<Duration>(
);
class PlayerActions {
PlayerActions(this._ref);
PlayerActions(this._ref) {
// Keep MediaItem.rating in sync with the drift-backed likedIds
// cache so external media surfaces (Wear's heart button, lock-
// screen favorite, Auto's like icon) reflect the current state
// even when the user toggles a like from somewhere other than
// the watch itself — e.g. tapping the heart on a TrackRow, the
// kebab menu, or another logged-in device propagating via SSE.
_ref.listen<AsyncValue<LikedIds>>(likedIdsProvider, (_, next) {
if (next.value == null) return;
_ref.read(audioHandlerProvider)
..refreshCurrentRating()
..refreshFavoriteControl();
});
}
final Ref _ref;
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
// Stage-by-stage timing so we can see exactly where the
// "tap → audio" delay lives. Look for "playTracks: ..." lines
// in the next log capture; each stage label is followed by its
// elapsed millis since the previous stage.
final sw = Stopwatch()..start();
int lap() {
final ms = sw.elapsedMilliseconds;
sw.reset();
sw.start();
return ms;
Future<void> playTracks(
List<TrackRef> tracks, {
int initialIndex = 0,
bool shuffle = false,
String? source,
}) async {
// shuffle=true means "play this pool randomly, starting at a
// random track" (client-side; used for non-system surfaces that
// want shuffle). System playlists instead fetch a server-ordered
// list and pass source — no client shuffle, the order is already
// rotation-aware (#415).
var startAt = initialIndex;
if (shuffle && tracks.length > 1) {
startAt = Random().nextInt(tracks.length);
}
final url = await _ref.read(serverUrlProvider.future);
debugPrint('playTracks: serverUrl ${lap()}ms');
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
debugPrint('playTracks: token ${lap()}ms');
final cache = _ref.read(albumCoverCacheProvider);
final audioCache = _ref.read(audioCacheManagerProvider);
final h = _ref.read(audioHandlerProvider)
@@ -78,12 +94,59 @@ class PlayerActions {
token: token,
coverCache: cache,
audioCacheManager: audioCache,
likeBridge: _buildLikeBridge(),
);
debugPrint('playTracks: configure ${lap()}ms');
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
debugPrint('playTracks: setQueue (${tracks.length} tracks) ${lap()}ms');
await h.setQueueFromTracks(tracks, initialIndex: startAt, source: source);
if (shuffle) {
await h.setShuffleMode(AudioServiceShuffleMode.all);
}
await h.play();
debugPrint('playTracks: play() returned ${lap()}ms');
}
/// Rebuilds a previously-persisted queue WITHOUT auto-playing, then
/// seeks to [position]. The resume-on-launch path (#54): the user
/// sees their last track in the mini bar, paused, and continues with
/// play / a media button. Mirrors playTracks' configure step so
/// streaming works after restore; intentionally no h.play().
Future<void> restoreQueue(
List<TrackRef> tracks, {
int initialIndex = 0,
Duration position = Duration.zero,
String? source,
}) async {
if (tracks.isEmpty) return;
final url = await _ref.read(serverUrlProvider.future);
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
final cache = _ref.read(albumCoverCacheProvider);
final audioCache = _ref.read(audioCacheManagerProvider);
final h = _ref.read(audioHandlerProvider)
..configure(
baseUrl: url ?? '',
token: token,
coverCache: cache,
audioCacheManager: audioCache,
likeBridge: _buildLikeBridge(),
);
await h.setQueueFromTracks(tracks, initialIndex: initialIndex, source: source);
if (position > Duration.zero) {
await h.seek(position);
}
}
/// Builds the adapter the audio handler uses to read + flip the
/// current track's like state in response to external media-
/// controller events (Wear's heart button, lock-screen favorite).
/// Constructed here because the audio handler is initialized in
/// main.dart before the ProviderScope exists; passing the bridge
/// in via configure() keeps the handler Riverpod-agnostic while
/// still letting it call into LikesController + likedIdsProvider.
LikeBridge _buildLikeBridge() {
return LikeBridge(
toggleTrackLike: (id) =>
_ref.read(likesControllerProvider).toggle(LikeKind.track, id),
isTrackLiked: (id) =>
_ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false,
);
}
Future<void> playNext(TrackRef track) async {
+3 -2
View File
@@ -1,5 +1,6 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -20,7 +21,7 @@ class QueueScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Queue', style: TextStyle(color: fs.parchment)),
@@ -84,7 +85,7 @@ class _QueueRow extends StatelessWidget {
if (isCurrent)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(Icons.graphic_eq, color: fs.accent, size: 16),
child: Icon(LucideIcons.audio_lines, color: fs.accent, size: 16),
),
Expanded(
child: Column(
@@ -1,54 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../cache/audio_cache_manager.dart';
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(
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, 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
@@ -90,6 +211,9 @@ class _Body extends ConsumerWidget {
ref.read(playerActionsProvider).playTracks(
playableRefs,
initialIndex: startIdx >= 0 ? startIdx : 0,
source: detail.playlist.refreshable
? detail.playlist.systemVariant
: null,
);
// Keep liveTrack referenced to avoid an unused-variable
// warning while we leave hooks for menu wiring later.
@@ -141,31 +265,24 @@ class _Header extends ConsumerWidget {
),
const Spacer(),
if (playable.isNotEmpty) ...[
OutlinedButton.icon(
key: const Key('download_playlist_button'),
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in playable) {
final id = t.trackId ?? '';
if (id.isEmpty) continue;
// Fire-and-forget; downloads happen in background.
// ignore: unawaited_futures
mgr.pin(id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
);
},
icon: const Icon(Icons.download, size: 16),
label: const Text('Download'),
),
const SizedBox(width: 8),
if (p.refreshable) ...[
OutlinedButton.icon(
key: const Key('regenerate_playlist_button'),
onPressed: () => _regenerate(context, ref),
icon: const Icon(LucideIcons.refresh_cw, size: 16),
label: const Text('Regenerate'),
),
const SizedBox(width: 8),
],
FilledButton.icon(
onPressed: () {
final refs = playable.map(_toTrackRef).toList(growable: false);
ref.read(playerActionsProvider).playTracks(refs);
ref.read(playerActionsProvider).playTracks(
refs,
source: p.refreshable ? p.systemVariant : null,
);
},
icon: const Icon(Icons.play_arrow),
icon: const Icon(LucideIcons.play),
label: const Text('Play'),
style: FilledButton.styleFrom(
backgroundColor: fs.accent,
@@ -177,53 +294,102 @@ class _Header extends ConsumerWidget {
]),
);
}
/// Forces a server-side rebuild of this system playlist. The
/// rebuild rotates the playlist UUID, so the old detail route now
/// 404s — rebind by pushReplacement-ing to the new id. The
/// aggregate list is invalidated too so the home row's tile points
/// at the fresh UUID. ScaffoldMessenger + router captured before
/// the await so we don't touch a stale BuildContext after.
Future<void> _regenerate(BuildContext context, WidgetRef ref) async {
final messenger = ScaffoldMessenger.of(context);
final router = GoRouter.of(context);
final p = detail.playlist;
try {
final api = await ref.read(playlistsApiProvider.future);
final newId = await api.refreshSystem(p.systemVariant!);
ref.invalidate(playlistsListProvider);
if (newId == null) {
messenger.showSnackBar(const SnackBar(
content: Text('Nothing to build yet — library is empty.'),
));
return;
}
messenger.showSnackBar(
SnackBar(content: Text('${p.name} regenerated')),
);
router.pushReplacement('/playlists/$newId');
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text("Couldn't regenerate: $e")),
);
}
}
}
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)),
]),
),
),
);
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -51,7 +52,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),
);
},
),
@@ -82,11 +83,11 @@ class _PlaylistTile extends StatelessWidget {
height: 56,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash)
? Icon(LucideIcons.list_music, color: fs.ash)
: ServerImage(
url: playlist.coverUrl,
fit: BoxFit.cover,
fallback: Icon(Icons.queue_music, color: fs.ash),
fallback: Icon(LucideIcons.list_music, color: fs.ash),
),
),
),
@@ -128,7 +129,7 @@ class _PlaylistTile extends StatelessWidget {
],
),
),
Icon(Icons.chevron_right, color: fs.ash),
Icon(LucideIcons.chevron_right, color: fs.ash),
]),
),
);
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
@@ -40,11 +41,6 @@ final playlistsListProvider =
fetchAndPopulate: () async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.list(kind: kind);
final ownedSysCount =
fresh.owned.where((p) => p.systemVariant != null).length;
debugPrint(
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
'(system=$ownedSysCount) public=${fresh.public.length}');
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
// every rebuild, so insertOrReplace alone leaves stale rows in
@@ -81,13 +77,6 @@ final playlistsListProvider =
.where((r) => r.userId != user.id && r.isPublic)
.map((r) => r.toRef())
.toList();
final ownedSys = owned.where((p) => p.isSystem).length;
final driftSys = filtered.where((r) => r.systemVariant != null).length;
debugPrint(
'playlistsListProvider($kind): drift rows=${rows.length} '
'filtered=${filtered.length} (system=$driftSys) '
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
'userId=${user.id}');
return PlaylistsList(owned: owned, public: pub);
},
isOnline: () async => (await ref
@@ -153,10 +142,7 @@ final playlistDetailProvider =
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(playlistsApiProvider.future);
debugPrint('playlistDetailProvider($id): calling get');
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
debugPrint(
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
// Collect the track + artist + album rows referenced by these
// playlist entries. Without writing the cachedTracks rows
@@ -262,8 +248,6 @@ final playlistDetailProvider =
);
}
});
debugPrint(
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
return true;
} catch (e, st) {
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
@@ -272,8 +256,6 @@ final playlistDetailProvider =
// 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) {
debugPrint(
'playlistDetailProvider($id): server says 404, evicting stale drift rows');
await db.batch((b) {
b.deleteWhere(
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
@@ -292,7 +274,6 @@ final playlistDetailProvider =
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
if (fetchAttempted) {
yield emptyDetail();
continue;
@@ -307,9 +288,6 @@ final playlistDetailProvider =
continue;
}
debugPrint(
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
@@ -318,8 +296,6 @@ final playlistDetailProvider =
// tracks for system playlists). Trigger the same fetch, drift
// watch re-emits with populated tracks.
if (trackRows.isEmpty && !fetchAttempted) {
debugPrint(
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
@@ -387,8 +363,41 @@ class _TrackRefRow {
final int durationSec;
}
/// Drift-first per #357 pattern. Reads from cached_system_playlists_
/// status (single-row JSON blob populated by /api/me/system-playlists-
/// status). Home Playlists row reads this every render to choose
/// between real cards and "building / pending / failed" placeholders
/// — drift-first means the row paints with the prior status instantly
/// rather than flickering through SystemPlaylistsStatus.empty() while
/// the REST round-trip resolves. SWR refresh on every visit keeps it
/// current.
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);
return MeApi(dio).systemPlaylistsStatus();
StreamProvider<SystemPlaylistsStatus>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedSystemPlaylistsStatusData, SystemPlaylistsStatus>(
driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(),
fetchAndPopulate: () async {
final dio = await ref.read(dioProvider.future);
final fresh = await MeApi(dio).systemPlaylistsStatus();
await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate(
CachedSystemPlaylistsStatusCompanion.insert(
json: jsonEncode({
'in_flight': fresh.inFlight,
'last_run_at': fresh.lastRunAt,
'last_error': fresh.lastError,
}),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? SystemPlaylistsStatus.empty()
: SystemPlaylistsStatus.fromJson(
jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
alwaysRefresh: true,
tag: 'systemPlaylistsStatus',
);
});
@@ -1,50 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../cache/offline_provider.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>()!;
// #427 S4: refreshable system playlists need the live build/
// shuffle endpoints — unavailable offline. Disable their play
// (use Shuffle all instead). User playlists play from cache.
final offline = ref.watch(offlineProvider);
final hasTracks = playlist.trackCount > 0 &&
!(offline && playlist.refreshable);
return SizedBox(
width: 176,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => context.push('/playlists/${playlist.id}'),
onTap: () =>
context.push('/playlists/${playlist.id}', extra: playlist),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
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),
),
),
// 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(LucideIcons.list_music, color: fs.ash, size: 56)
: ServerImage(
url: playlist.coverUrl,
fit: BoxFit.cover,
fallback:
Icon(LucideIcons.list_music, color: fs.ash, size: 56),
),
),
),
Positioned(
bottom: 6,
right: 6,
child: PlayCircleButton(
enabled: hasTracks,
onPressed: () => _playPlaylist(ref),
),
),
// System playlists get a refresh affordance so the
// user can force a fresh mix instead of waiting for
// the daily 03:00 rebuild. Mirrors the web kebab.
if (playlist.refreshable)
Positioned(
top: 2,
right: 2,
child: PopupMenuButton<String>(
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18),
tooltip: 'Playlist actions',
color: fs.iron,
onSelected: (_) => _refresh(context, ref),
itemBuilder: (_) => [
PopupMenuItem<String>(
value: 'refresh',
child: Row(children: [
Icon(LucideIcons.refresh_cw, color: fs.parchment, size: 18),
const SizedBox(width: 8),
Text(_refreshLabel,
style: TextStyle(color: fs.parchment)),
]),
),
],
),
),
],
),
const SizedBox(height: 8),
Text(
@@ -69,10 +123,102 @@ class PlaylistCard extends StatelessWidget {
),
),
),
if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '')
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_refreshedLabel(playlist.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
]),
),
),
),
);
}
String get _refreshLabel => 'Refresh ${playlist.name}';
/// #417: system playlists atomic-replace on rebuild, so createdAt
/// is the last-rotated time. Friendly wording mirrors the web
/// PlaylistCard. Empty string when the timestamp is unparseable.
String _refreshedLabel(String iso) {
final t = DateTime.tryParse(iso);
if (t == null) return '';
final now = DateTime.now();
final mins = now.difference(t).inMinutes;
if (mins < 5) return 'Refreshed just now';
final startOfToday = DateTime(now.year, now.month, now.day);
if (!t.isBefore(startOfToday)) return 'Refreshed today';
final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays;
if (days <= 1) return 'Refreshed yesterday';
if (days < 7) return 'Refreshed $days days ago';
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return 'Refreshed ${months[t.month - 1]} ${t.day}';
}
/// Forces a server-side rebuild of this system playlist, then
/// invalidates the aggregate list so the rotated UUID + new tracks
/// land on the next read. ScaffoldMessenger is captured before the
/// await so we don't touch a stale BuildContext afterward.
Future<void> _refresh(BuildContext context, WidgetRef ref) async {
final messenger = ScaffoldMessenger.of(context);
try {
final api = await ref.read(playlistsApiProvider.future);
await api.refreshSystem(playlist.systemVariant!);
ref.invalidate(playlistsListProvider);
messenger.showSnackBar(
SnackBar(content: Text('${playlist.name} refreshed')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text("Couldn't refresh: $e")),
);
}
}
/// 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);
// Refreshable (singleton) system playlists: fetch the server's
// rotation-aware order (#415) and play as-is, tagged with the
// source so the reporter advances rotation. User playlists AND
// non-singleton system kinds (songs_like_artist — no by-kind
// endpoint) play the stored order via get(), untagged.
final detail = playlist.refreshable
? await api.systemShuffle(playlist.systemVariant!)
: 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;
// Rotation-aware kinds arrive pre-ordered from the server — play
// as-is, tagged so the reporter advances rotation. No client
// shuffle. Everything else plays stored order, untagged.
await ref.read(playerActionsProvider).playTracks(
refs,
initialIndex: 0,
source: playlist.refreshable ? playlist.systemVariant : null,
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../theme/theme_extension.dart';
@@ -65,9 +66,9 @@ class PlaylistPlaceholderCard extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent),
);
case 'failed':
return Icon(Icons.warning_amber, color: fs.error, size: 32);
return Icon(LucideIcons.triangle_alert, color: fs.error, size: 32);
default:
return Icon(Icons.queue_music, color: fs.ash, size: 40);
return Icon(LucideIcons.list_music, color: fs.ash, size: 40);
}
}
@@ -1,7 +1,14 @@
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 '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
@@ -10,58 +17,165 @@ 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);
Error.throwWithStackTrace(e, st);
} catch (_) {
// REST failed — don't roll back; queue for replay so the user's
// "hide this track" intent persists offline.
await ref.read(mutationQueueProvider).enqueue(
MutationKinds.quarantineFlag,
{'trackId': track.id, 'reason': reason, 'notes': notes},
);
}
}
/// Optimistic unflag: removes the row, calls server, rolls back on error.
/// Optimistic unflag: removes the drift row, calls server, queues
/// the call on failure so the unhide intent persists offline.
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);
Error.throwWithStackTrace(e, st);
} catch (_) {
// Don't restore the drift row; queue the unflag for replay.
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId});
}
}
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 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/requests.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/admin_request.dart';
@@ -41,17 +42,20 @@ class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
}
}
/// Optimistic remove + REST cancel. Restores the row on failure so
/// the user can retry — silent failure would lie about success.
/// Optimistic remove + REST cancel. On failure the row stays
/// removed in-memory and the cancel is queued for replay — this
/// keeps the user's intent visible across network loss instead of
/// restoring a row they explicitly asked to remove.
Future<void> cancel(String id) async {
final api = await ref.read(requestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
state = AsyncData(current.where((r) => r.id != id).toList());
try {
await api.cancel(id);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
} catch (_) {
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.requestCancel, {'id': id});
}
}
}
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
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 +15,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(
@@ -21,7 +33,7 @@ class RequestsScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
@@ -119,9 +131,9 @@ class _RequestRow extends StatelessWidget {
Widget _kindAvatar(FabledSwordTheme fs) {
final icon = switch (request.kind) {
'artist' => Icons.album,
'album' => Icons.library_music,
_ => Icons.music_note,
'artist' => LucideIcons.disc_3,
'album' => LucideIcons.library_big,
_ => LucideIcons.music,
};
return CircleAvatar(
backgroundColor: fs.iron,
@@ -153,7 +165,7 @@ class _RequestRow extends StatelessWidget {
);
if (ok == true) onCancel();
},
icon: const Icon(Icons.close, size: 16),
icon: const Icon(LucideIcons.x, size: 16),
label: const Text('Cancel'),
style: TextButton.styleFrom(foregroundColor: fs.ash),
);
@@ -161,7 +173,7 @@ class _RequestRow extends StatelessWidget {
if (request.status == 'completed' && href != null) {
return TextButton.icon(
onPressed: () => context.push(href),
icon: const Icon(Icons.play_arrow, size: 16),
icon: const Icon(LucideIcons.play, size: 16),
label: const Text('Listen'),
style: TextButton.styleFrom(foregroundColor: fs.accent),
);
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -47,7 +48,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: TextField(
@@ -67,7 +68,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
actions: [
if (_controller.text.isNotEmpty)
IconButton(
icon: Icon(Icons.clear, color: fs.ash),
icon: Icon(LucideIcons.x, color: fs.ash),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).set('');
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../theme/theme_extension.dart';
import '../update/client_update_provider.dart';
import '../update/update_info.dart';
final _packageInfoProvider = FutureProvider<PackageInfo>((_) {
return PackageInfo.fromPlatform();
});
class AboutSection extends ConsumerStatefulWidget {
const AboutSection({super.key});
@override
ConsumerState<AboutSection> createState() => _AboutSectionState();
}
enum _InstallStage { idle, downloading, error }
class _AboutSectionState extends ConsumerState<AboutSection> {
DateTime? _lastChecked;
bool _checking = false;
_InstallStage _installStage = _InstallStage.idle;
double _installProgress = 0;
String? _installError;
Future<void> _checkNow() async {
setState(() => _checking = true);
ref.invalidate(clientUpdateProvider);
try {
await ref.read(clientUpdateProvider.future);
} finally {
if (mounted) {
setState(() {
_checking = false;
_lastChecked = DateTime.now();
});
}
}
}
Future<void> _install(UpdateInfo info) async {
setState(() {
_installStage = _InstallStage.downloading;
_installProgress = 0;
_installError = null;
});
try {
final installer = await ref.read(updateInstallerProvider.future);
final path = await installer.download(
info.apkUrl,
onProgress: (p) {
if (mounted) setState(() => _installProgress = p);
},
);
await installer.install(path);
} catch (e) {
if (!mounted) return;
setState(() {
_installStage = _InstallStage.error;
_installError = '$e';
});
}
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final pkg = ref.watch(_packageInfoProvider);
final update = ref.watch(clientUpdateProvider);
final installed = pkg.value == null
? ''
: '${pkg.value!.version}+${pkg.value!.buildNumber}';
final UpdateInfo? available = update.value;
final hasUpdate = available != null;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 8),
child: _SectionHeader('About'),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
Icon(LucideIcons.info, color: fs.parchment, size: 18),
const SizedBox(width: 8),
Text('Installed version',
style: TextStyle(color: fs.parchment, fontSize: 14)),
const Spacer(),
Text(installed,
style: TextStyle(
color: fs.ash,
fontSize: 13,
fontFamily: 'JetBrainsMono')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: Row(
children: [
Icon(LucideIcons.cloud, color: fs.parchment, size: 18),
const SizedBox(width: 8),
Text('Latest version',
style: TextStyle(color: fs.parchment, fontSize: 14)),
const Spacer(),
Text(
hasUpdate ? available.version : _statusFor(update, installed),
style: TextStyle(
color: hasUpdate ? fs.accent : fs.ash,
fontSize: 13,
fontFamily: 'JetBrainsMono'),
),
],
),
),
if (_lastChecked != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: Text(
'Last checked ${_formatTime(_lastChecked!)}',
style: TextStyle(color: fs.ash, fontSize: 11),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
FilledButton.icon(
key: const Key('about_check_for_updates'),
onPressed: _checking ? null : _checkNow,
style: FilledButton.styleFrom(
backgroundColor: fs.accent,
foregroundColor: fs.parchment,
),
icon: _checking
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(fs.parchment),
),
)
: const Icon(LucideIcons.refresh_cw, size: 18),
label: Text(_checking ? 'Checking…' : 'Check for updates'),
),
if (hasUpdate) ...[
const SizedBox(width: 12),
FilledButton.icon(
key: const Key('about_install_update'),
onPressed: _installStage == _InstallStage.downloading
? null
: () => _install(available),
style: FilledButton.styleFrom(
backgroundColor: fs.moss,
foregroundColor: fs.parchment,
),
icon: _installStage == _InstallStage.downloading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
value: _installProgress > 0 ? _installProgress : null,
valueColor: AlwaysStoppedAnimation(fs.parchment),
),
)
: const Icon(LucideIcons.download, size: 18),
label: Text(
_installStage == _InstallStage.downloading
? 'Downloading…'
: _installStage == _InstallStage.error
? 'Retry install'
: 'Install ${available.version}',
),
),
],
],
),
),
if (_installStage == _InstallStage.error && _installError != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(_installError!,
style: TextStyle(color: fs.error, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis),
),
]);
}
String _statusFor(AsyncValue<UpdateInfo?> update, String installed) {
if (update.isLoading) return 'Checking…';
if (update.hasError) return 'Check failed';
return 'Up to date';
}
String _formatTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
return '$h:$m';
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.label);
final String label;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Text(
label,
style: TextStyle(
color: fs.parchment,
fontSize: 16,
fontWeight: FontWeight.w500,
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -9,6 +10,7 @@ import '../library/library_providers.dart' show dioProvider;
import '../models/my_profile.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'about_section.dart';
import 'storage_section.dart';
import '../theme/theme_mode_provider.dart';
@@ -36,7 +38,7 @@ class SettingsScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Settings', style: TextStyle(color: fs.parchment)),
@@ -56,6 +58,8 @@ class SettingsScreen extends ConsumerWidget {
_PasswordSection(),
_Divider(),
_ListenBrainzSection(),
_Divider(),
AboutSection(),
_AdminSection(),
SizedBox(height: 96),
],
@@ -104,7 +108,7 @@ class _RequestsSection extends StatelessWidget {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
key: const Key('settings_requests_card'),
leading: Icon(Icons.queue_music, color: fs.parchment),
leading: Icon(LucideIcons.list_music, color: fs.parchment),
title: Text(
'My requests',
style: TextStyle(
@@ -117,7 +121,7 @@ class _RequestsSection extends StatelessWidget {
"Track what you've asked Minstrel to add",
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
onTap: () => context.push('/requests'),
);
}
@@ -140,7 +144,7 @@ class _AdminSection extends ConsumerWidget {
const _Divider(),
ListTile(
key: const Key('settings_admin_card'),
leading: Icon(Icons.shield, color: fs.parchment),
leading: Icon(LucideIcons.shield, color: fs.parchment),
title: Text(
'Admin',
style: TextStyle(
@@ -153,7 +157,7 @@ class _AdminSection extends ConsumerWidget {
'Manage requests, quarantine, and users',
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
trailing: Icon(LucideIcons.chevron_right, color: fs.ash),
onTap: () => context.push('/admin'),
),
],
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/cache_settings_provider.dart';
import '../cache/sync_controller.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../theme/theme_extension.dart';
/// Settings card: usage display, cap selector, prefetch window selector,
/// cache-liked toggle, Clear cache + Sync now buttons.
/// Settings card: per-bucket usage, two cap selectors (Liked +
/// Recently-played), prefetch window, cache-liked toggle, Clear
/// cache + Sync now. #427 S2/S3: two independent budgets.
class StorageSection extends ConsumerStatefulWidget {
const StorageSection({super.key});
@@ -16,7 +19,7 @@ class StorageSection extends ConsumerStatefulWidget {
}
class _StorageSectionState extends ConsumerState<StorageSection> {
int? _usageBytes;
BucketUsage? _usage;
bool _syncing = false;
@override
@@ -25,10 +28,13 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
_refreshUsage();
}
Set<String> _likedSet() =>
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
Future<void> _refreshUsage() async {
final mgr = ref.read(audioCacheManagerProvider);
final used = await mgr.usageBytes();
if (mounted) setState(() => _usageBytes = used);
final u = await mgr.bucketUsage(_likedSet());
if (mounted) setState(() => _usage = u);
}
String _fmtBytes(int? n) {
@@ -36,10 +42,14 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
if (n == 0) return '0 B';
if (n < 1024) return '$n B';
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
if (n < 1024 * 1024 * 1024) {
return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
}
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
}
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -65,17 +75,32 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
fontSize: 18,
fontWeight: FontWeight.w500)),
const SizedBox(height: 12),
Row(children: [
Text('Cache usage', style: TextStyle(color: fs.ash)),
const Spacer(),
Text(
'${_fmtBytes(_usageBytes)} / '
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
style: TextStyle(color: fs.parchment),
),
]),
_usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
const SizedBox(height: 4),
_usageRow('Recently played', _usage?.rolling,
s.rollingCapBytes, fs),
const SizedBox(height: 16),
_capSelector(s, fs),
_capSelector(
'Liked cache limit',
const Key('liked_cap_selector'),
s.likedCapBytes,
(v) => ref
.read(cacheSettingsProvider.notifier)
.setLikedCapBytes(v),
s,
fs,
),
const SizedBox(height: 8),
_capSelector(
'Recently-played cache limit',
const Key('rolling_cap_selector'),
s.rollingCapBytes,
(v) => ref
.read(cacheSettingsProvider.notifier)
.setRollingCapBytes(v),
s,
fs,
),
const SizedBox(height: 8),
_prefetchSelector(s, fs),
const SizedBox(height: 8),
@@ -116,7 +141,7 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, size: 16),
: const Icon(LucideIcons.refresh_cw, size: 16),
label: const Text('Sync now'),
),
]),
@@ -128,29 +153,52 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
);
}
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [
(1024 * 1024 * 1024, '1 GB'),
(5 * 1024 * 1024 * 1024, '5 GB'),
(10 * 1024 * 1024 * 1024, '10 GB'),
(25 * 1024 * 1024 * 1024, '25 GB'),
(0, 'Unlimited'),
];
Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) {
return Row(children: [
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
Text(label, style: TextStyle(color: fs.ash)),
const Spacer(),
Text('${_fmtBytes(used)} / ${_cap(cap)}',
style: TextStyle(color: fs.parchment)),
]);
}
static const _capOptions = [
(1024 * 1024 * 1024, '1 GB'),
(5 * 1024 * 1024 * 1024, '5 GB'),
(10 * 1024 * 1024 * 1024, '10 GB'),
(25 * 1024 * 1024 * 1024, '25 GB'),
(0, 'Unlimited'),
];
Widget _capSelector(
String label,
Key key,
int current,
Future<void> Function(int) setCap,
CacheSettings s,
FabledSwordTheme fs,
) {
return Row(children: [
Expanded(child: Text(label, style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('cap_selector'),
value: options.any((o) => o.$1 == s.capBytes)
? s.capBytes
key: key,
value: _capOptions.any((o) => o.$1 == current)
? current
: 5 * 1024 * 1024 * 1024,
items: options
items: _capOptions
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
.toList(),
onChanged: (v) async {
if (v == null) return;
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
if (v > 0) {
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
await setCap(v);
// Enforce immediately against the freshest settings.
final fresh = ref.read(cacheSettingsProvider).value;
if (fresh != null) {
await ref.read(audioCacheManagerProvider).evictBuckets(
likedCap: fresh.likedCapBytes,
rollingCap: fresh.rollingCapBytes,
liked: _likedSet(),
);
}
await _refreshUsage();
},
@@ -161,7 +209,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [1, 3, 5, 7, 10];
return Row(children: [
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
Expanded(
child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('prefetch_selector'),
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
@@ -183,8 +232,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
builder: (ctx) => AlertDialog(
title: const Text('Clear cache?'),
content: const Text(
'This deletes all cached audio files (including manually downloaded ones). '
'The next play of any track will re-download from the server.',
'This deletes all cached audio (liked and recently-played). '
'The next play of any track re-downloads from the server.',
),
actions: [
TextButton(
@@ -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;
}
}
+10 -1
View File
@@ -9,6 +9,7 @@ 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';
@@ -120,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()),
@@ -147,6 +151,11 @@ class _ShellWithPlayerBar extends ConsumerWidget {
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: hasBanner
@@ -0,0 +1,43 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// The Lucide "heart" silhouette rendered as either an outline (stroke)
/// or a solid fill. Lucide ships only an outline heart, so the liked
/// state fills the same authoritative Lucide path (verified verbatim
/// from lucide-icons/lucide) — keeping both states visually Lucide
/// rather than introducing a Material filled heart. Used by LikeButton
/// (and, re-derived to a VectorDrawable, by the media notification).
class LucideHeart extends StatelessWidget {
const LucideHeart({
required this.filled,
required this.color,
this.size = 22,
super.key,
});
final bool filled;
final Color color;
final double size;
static const _path =
'M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 '
'22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5'
'-3-3.2-3-5.5';
@override
Widget build(BuildContext context) {
final svg = filled
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
'<path d="$_path" fill="currentColor"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
'<path d="$_path" fill="none" stroke="currentColor" '
'stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"/></svg>';
return SvgPicture.string(
svg,
width: size,
height: size,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -26,27 +27,27 @@ class MainAppBarActions extends ConsumerWidget {
if (currentRoute != '/home')
IconButton(
key: const Key('app_bar_home'),
icon: Icon(Icons.home, color: fs.parchment),
icon: Icon(LucideIcons.house, color: fs.parchment),
tooltip: 'Home',
onPressed: () => context.go('/home'),
),
if (currentRoute != '/library')
IconButton(
key: const Key('app_bar_library'),
icon: Icon(Icons.library_music, color: fs.parchment),
icon: Icon(LucideIcons.library_big, color: fs.parchment),
tooltip: 'Library',
onPressed: () => context.push('/library'),
),
if (currentRoute != '/search')
IconButton(
key: const Key('app_bar_search'),
icon: Icon(Icons.search, color: fs.parchment),
icon: Icon(LucideIcons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
PopupMenuButton<String>(
key: const Key('app_bar_overflow'),
icon: Icon(Icons.more_vert, color: fs.parchment),
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment),
tooltip: 'More',
onSelected: (route) => context.push(route),
itemBuilder: (_) => [
@@ -1,9 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
show HttpExceptionWithStatus;
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 +15,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,17 +39,18 @@ 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.
// 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 fired Image.network with headers:null at that
// moment, the network image cache would lock in the 401 response
// and never retry. Instead, hold the fallback until the token is
// available, then mount the Image with the auth header attached.
// 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,
@@ -49,14 +58,34 @@ class ServerImage extends ConsumerWidget {
data: (token) {
final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'}
: null;
return Image.network(
resolved,
: <String, String>{};
return CachedNetworkImage(
imageUrl: resolved,
httpHeaders: headers,
fit: fit,
headers: headers,
// 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.
errorBuilder: (_, __, ___) => empty,
errorWidget: (_, __, ___) => empty,
// Filter expected 404s out of dev console noise: playlist
// collages aren't built until the playlist has tracks and
// the build job runs (system playlists like For-You /
// Discover hit this on first-render). The errorWidget
// already renders the fallback for the user; this just
// keeps the dev console clean. Non-404 errors still
// surface so auth/connectivity issues remain visible.
errorListener: (err) {
if (err is HttpExceptionWithStatus && err.statusCode == 404) {
return;
}
debugPrint('ServerImage: $err');
},
);
},
);
@@ -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),
],
),
),
],
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../playlists/playlists_provider.dart';
@@ -70,7 +71,7 @@ class AddToPlaylistSheet extends ConsumerWidget {
final p = owned[i];
return ListTile(
key: Key('add_to_playlist_${p.id}'),
leading: Icon(Icons.queue_music, color: fs.parchment),
leading: Icon(LucideIcons.list_music, color: fs.parchment),
title: Text(
p.name,
style: TextStyle(color: fs.parchment),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../../models/track.dart';
import '../../../theme/theme_extension.dart';
@@ -11,6 +12,7 @@ class TrackActionsButton extends StatelessWidget {
super.key,
required this.track,
this.hideQueueActions = false,
this.onNavigate,
});
final TrackRef track;
@@ -19,16 +21,22 @@ 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>()!;
return IconButton(
icon: Icon(Icons.more_vert, color: fs.ash, size: 18),
icon: Icon(LucideIcons.ellipsis_vertical, color: fs.ash, size: 18),
tooltip: 'Track actions',
onPressed: () => TrackActionsSheet.show(
context,
track,
hideQueueActions: hideQueueActions,
onNavigate: onNavigate,
),
);
}
@@ -1,8 +1,13 @@
import 'package:drift/drift.dart' as drift;
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../api/endpoints/likes.dart';
import '../../../cache/audio_cache_manager.dart' show appDbProvider;
import '../../../cache/db.dart';
import '../../../cache/mutation_queue.dart';
import '../../../likes/likes_provider.dart';
import '../../../models/track.dart';
import '../../../player/player_provider.dart';
@@ -21,15 +26,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 +54,7 @@ class TrackActionsSheet extends ConsumerWidget {
builder: (_) => TrackActionsSheet(
track: track,
hideQueueActions: hideQueueActions,
onNavigate: onNavigate,
),
);
}
@@ -56,7 +74,7 @@ class TrackActionsSheet extends ConsumerWidget {
if (!hideQueueActions) ...[
_MenuItem(
key: const Key('track_actions_play_next'),
icon: Icons.playlist_play,
icon: LucideIcons.list_video,
label: 'Play next',
onTap: () async {
Navigator.pop(context);
@@ -70,7 +88,7 @@ class TrackActionsSheet extends ConsumerWidget {
),
_MenuItem(
key: const Key('track_actions_enqueue'),
icon: Icons.queue_music,
icon: LucideIcons.list_music,
label: 'Add to queue',
onTap: () async {
Navigator.pop(context);
@@ -86,7 +104,7 @@ class TrackActionsSheet extends ConsumerWidget {
],
_MenuItem(
key: const Key('track_actions_like'),
icon: liked ? Icons.favorite : Icons.favorite_border,
icon: LucideIcons.heart,
label: liked ? 'Unlike' : 'Like',
onTap: () async {
Navigator.pop(context);
@@ -95,7 +113,7 @@ class TrackActionsSheet extends ConsumerWidget {
),
_MenuItem(
key: const Key('track_actions_add_to_playlist'),
icon: Icons.playlist_add,
icon: LucideIcons.list_plus,
label: 'Add to playlist…',
onTap: () async {
Navigator.pop(context);
@@ -119,7 +137,7 @@ class TrackActionsSheet extends ConsumerWidget {
),
_MenuItem(
key: const Key('track_actions_start_radio'),
icon: Icons.radio,
icon: LucideIcons.radio,
label: 'Start radio',
onTap: () async {
Navigator.pop(context);
@@ -137,26 +155,36 @@ class TrackActionsSheet extends ConsumerWidget {
const _Divider(),
_MenuItem(
key: const Key('track_actions_go_to_album'),
icon: Icons.album,
icon: LucideIcons.disc_3,
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(
key: const Key('track_actions_go_to_artist'),
icon: Icons.person,
icon: LucideIcons.user,
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(),
_MenuItem(
key: const Key('track_actions_hide'),
icon: hidden ? Icons.visibility : Icons.visibility_off,
icon: hidden ? LucideIcons.eye : LucideIcons.eye_off,
label: hidden ? 'Unhide' : 'Hide',
onTap: () async {
Navigator.pop(context);
@@ -232,7 +260,38 @@ typedef AddToPlaylistAction = Future<void> Function(String playlistId, String tr
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
return (playlistId, trackId) async {
final api = await ref.read(playlistsApiProvider.future);
await api.appendTracks(playlistId, [trackId]);
final db = ref.read(appDbProvider);
// Optimistic drift write so the playlist detail screen shows the
// new track instantly. The position is best-effort — append after
// the current max for this playlist. The server's authoritative
// position lands on next playlistDetailProvider fetch (SWR), or
// when the queued mutation replays.
final maxPos = await (db.selectOnly(db.cachedPlaylistTracks)
..addColumns([db.cachedPlaylistTracks.position.max()])
..where(db.cachedPlaylistTracks.playlistId.equals(playlistId)))
.getSingleOrNull();
final nextPos =
((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1);
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
CachedPlaylistTracksCompanion.insert(
playlistId: playlistId,
trackId: trackId,
position: drift.Value(nextPos),
),
);
try {
final api = await ref.read(playlistsApiProvider.future);
await api.appendTracks(playlistId, [trackId]);
} catch (_) {
// REST failed — keep the optimistic drift row; queue the call.
await ref.read(mutationQueueProvider).enqueue(
MutationKinds.playlistAppend,
{
'playlistId': playlistId,
'trackIds': [trackId],
},
);
}
};
});
@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pub_semver/pub_semver.dart';
@@ -6,61 +10,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(LucideIcons.triangle_alert, 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 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../theme/theme_extension.dart';
@@ -66,7 +67,7 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.system_update, color: fs.parchment, size: 16),
Icon(LucideIcons.download, color: fs.parchment, size: 16),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -132,7 +133,7 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
tooltip: 'Dismiss',
padding: EdgeInsets.zero,
iconSize: 16,
icon: Icon(Icons.close, color: fs.ash),
icon: Icon(LucideIcons.x, color: fs.ash),
onPressed: () => _onDismiss(info),
),
),
+49 -1
View File
@@ -121,6 +121,30 @@ packages:
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:
@@ -327,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"
@@ -411,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
@@ -664,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:
@@ -688,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:
+27 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 2026.5.11+1
version: 2026.5.19+12
environment:
sdk: '>=3.5.0 <4.0.0'
@@ -14,9 +14,20 @@ dependencies:
dio: ^5.7.0
just_audio: ^0.10.5
audio_service: ^0.18.15
# Audio focus + interruptions + becoming-noisy. just_audio auto-manages
# session activation once configured; the app must still handle
# interruption/becoming-noisy itself (just_audio does not). Same author
# as just_audio/audio_service so versions track together.
audio_session: ^0.2.3
flutter_secure_storage: ^10.1.0
go_router: ^17.2.3
flutter_svg: ^2.0.16
# Lucide icon set (design system mandates Lucide, not Material).
# Icons exposed as LucideIcons.<snake_case> IconData usable in Icon().
flutter_lucide: ^1.11.0
# Runtime POST_NOTIFICATIONS request (Android 13+ denies-by-default
# until asked; the media notification is suppressed without it).
permission_handler: ^12.0.1
google_fonts: ^8.1.0
# 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1
# until either lib bumps win32 to 6.x.
@@ -28,6 +39,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:
+36 -17
View File
@@ -4,6 +4,7 @@ library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart';
@@ -54,7 +55,8 @@ void main() {
expect(await mgr.usageBytes(), 350);
});
test('eviction order: incidental first, manual never', skip: _skipDrift, () async {
test('rolling cap evicts non-liked LRU; liked protected',
skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
@@ -63,30 +65,47 @@ void main() {
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f1 = File('${tmp.path}/audio_cache/inc.mp3');
await f1.create(recursive: true);
await f1.writeAsBytes(List.filled(100, 0));
final f2 = File('${tmp.path}/audio_cache/man.mp3');
await f2.create(recursive: true);
await f2.writeAsBytes(List.filled(100, 0));
Future<void> mk(String id) async {
final f = File('${tmp.path}/audio_cache/$id.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(100, 0));
}
await mk('old');
await mk('new');
await mk('lik');
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
// 'old' has the older lastPlayedAt → evicted first.
AudioCacheIndexCompanion.insert(
trackId: 'inc',
path: f1.path,
trackId: 'old',
path: '${tmp.path}/audio_cache/old.mp3',
sizeBytes: 100,
source: CacheSource.incidental),
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2020))),
AudioCacheIndexCompanion.insert(
trackId: 'man',
path: f2.path,
trackId: 'new',
path: '${tmp.path}/audio_cache/new.mp3',
sizeBytes: 100,
source: CacheSource.manual),
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2024))),
AudioCacheIndexCompanion.insert(
trackId: 'lik',
path: '${tmp.path}/audio_cache/lik.mp3',
sizeBytes: 100,
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2019))),
]);
});
expect(await mgr.usageBytes(), 200);
await mgr.evict(targetBytes: 100);
expect(await mgr.isCached('inc'), false);
expect(await mgr.isCached('man'), true);
final liked = {'lik'};
final u = await mgr.bucketUsage(liked);
expect(u.liked, 100);
expect(u.rolling, 200);
// Rolling cap fits one 100-byte file; Liked cap huge.
await mgr.evictBuckets(
likedCap: 1 << 30, rollingCap: 100, liked: liked);
expect(await mgr.isCached('old'), false); // oldest rolling, evicted
expect(await mgr.isCached('new'), true); // newer rolling, kept
expect(await mgr.isCached('lik'), true); // liked, protected
});
test('clearAll removes everything including manual', skip: _skipDrift, () async {
+9 -2
View File
@@ -32,9 +32,16 @@ void main() {
isOnline: () async => true,
);
final firstFuture = results.first;
// Post-coldFetchAttempted guard, cacheFirst yields the current
// (still-empty) rows after the fetch returns, so the stream
// never hangs when populate is a no-op for this filter. The
// simulated drift re-emit then yields the populated rows.
// Capture the Future *before* feeding the controller so the
// listener is subscribed when the first add() lands.
final emissionsFuture = results.take(2).toList();
controller.add([]);
expect(await firstFuture, 42);
final emissions = await emissionsFuture;
expect(emissions, [0, 42]);
expect(fetchCalled, 1);
await controller.close();
});
@@ -27,7 +27,9 @@ void main() {
addTearDown(container.dispose);
final s = await container.read(cacheSettingsProvider.future);
expect(s.capBytes, CacheSettings.defaults.capBytes);
expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes);
expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes);
expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024);
expect(s.prefetchWindow, 5);
expect(s.cacheLikedTracks, true);
});
@@ -5,53 +5,51 @@ 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()),
),
systemPlaylistsStatusProvider.overrideWith(
(ref) async => SystemPlaylistsStatus.empty(),
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
),
],
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()),
),
systemPlaylistsStatusProvider.overrideWith(
(ref) async => SystemPlaylistsStatus.empty(),
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
),
],
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
@@ -87,13 +85,13 @@ 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: [])),
),
systemPlaylistsStatusProvider.overrideWith(
(ref) async => SystemPlaylistsStatus.empty(),
(ref) => Stream.value(SystemPlaylistsStatus.empty()),
),
],
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
@@ -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,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/models/playlist.dart';
@@ -35,24 +37,48 @@ const _forYou = Playlist(
void main() {
testWidgets('renders name and no badge for user playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _userPlaylist),
),
));
)));
expect(find.text('Road trip'), findsOneWidget);
expect(find.text('for you'), findsNothing);
});
testWidgets('renders system badge for system playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _forYou),
),
));
)));
expect(find.text('For You'), findsOneWidget);
expect(find.text('for you'), findsOneWidget);
});
testWidgets('shows refresh kebab on system playlists', (tester) async {
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _forYou),
),
)));
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsOneWidget);
});
testWidgets('no refresh kebab on user playlists', (tester) async {
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _userPlaylist),
),
)));
expect(find.byIcon(LucideIcons.ellipsis_vertical), findsNothing);
});
}
@@ -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,75 +49,95 @@ 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 keeps drift optimistic + queues mutation 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);
await expectLater(
() => container
.read(myQuarantineProvider.notifier)
.flag(_track, 'bad_rip', ''),
throwsException,
);
expect(container.read(myQuarantineProvider).value, isEmpty);
// No longer rethrows — flag swallows the REST failure and queues
// for replay so the user's intent persists offline.
await container
.read(myQuarantineProvider.notifier)
.flag(_track, 'bad_rip', '');
final rows = await db.select(db.cachedQuarantineMine).get();
expect(rows, hasLength(1),
reason: 'optimistic drift row should persist across REST failure');
final mutations = await db.select(db.cachedMutations).get();
expect(mutations, hasLength(1),
reason: 'failed flag should have been enqueued for replay');
expect(mutations.first.kind, 'quarantine.flag');
});
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);
@@ -46,12 +46,14 @@ void main() {
await t.pumpAndSettle();
expect(find.text('Storage'), findsOneWidget);
expect(find.text('Cache size limit'), findsOneWidget);
expect(find.text('Liked cache limit'), findsOneWidget);
expect(find.text('Recently-played cache limit'), findsOneWidget);
expect(find.text('Pre-fetch ahead'), findsOneWidget);
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
expect(find.byKey(const Key('sync_now_button')), findsOneWidget);
expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget);
expect(find.byKey(const Key('cap_selector')), findsOneWidget);
expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget);
expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget);
expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
});
+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

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