Commit Graph

256 Commits

Author SHA1 Message Date
bvandeusen 750bc69204 chore(deps): Tier A cross-ecosystem patch/minor sweep
No expected behavior changes; CI verifies. Tracked in Fable #463
under audit note #460.

Flutter (pubspec.yaml):
- flutter_secure_storage ^10.1.0 → ^10.2.0
- mocktail ^1.0.4 → ^1.0.5

Web (package.json + package-lock.json):
- @sveltejs/adapter-static ^3.0.6 → ^3.0.10
- @types/node ^25.6.0 → ^25.9.1
- autoprefixer ^10.4.20 → ^10.5.0
- postcss ^8.4.49 → ^8.5.15
- svelte-check ^4.0.5 → ^4.4.8

Tools (package.json + package-lock.json):
- sharp ^0.33.5 → ^0.34.5 (the lockfile diff is large because
  sharp ships pre-built binaries for many platform/arch combos;
  each version revs all those entries)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:58:24 -04:00
bvandeusen 66caee76fd chore(deps): drop unused cupertino_icons + tslib
Both verified unused at the source/config level (Fable #461 +
audit note #460):

- cupertino_icons: zero `CupertinoIcons` / cupertino imports in
  flutter_client/lib/. Pure `flutter create` template residue;
  the design system mandates Lucide.
- tslib: web/tsconfig.json does not set `importHelpers: true`,
  so TypeScript inlines helpers per-file. Declared peer with no
  runtime consumer.

`npm uninstall tslib --save-dev` updated package-lock.json
surgically (8 lines removed for the tslib entry only). No other
deps disturbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:30:50 -04:00
bvandeusen 5c1a5f5e8b chore(flutter): bump version to 2026.5.19+13 for v2026.05.19.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:47:28 -04:00
bvandeusen 154626ae94 feat(player): stall watchdog + bounded retry + skip-to-cached (#66)
Reported: on poor coverage a track ends and the next (uncached) track
never starts — streams, hangs, no retry. Root cause: a buffering stall
emits NO error event so the onError path never fires and there was no
stall watchdog; even on a real error _handlePlaybackError immediately
skipped the literal-next (likely also-unreachable) source with no retry.

- _reconcileStallWatchdog: while playing+buffering, a 15s window; if
  buffered position hasn't advanced it's a dead stream → recover; if
  progressing, re-arm (slow-but-downloading is fine). Driven from
  _broadcastState like the idle/position reconcilers.
- _recoverPlayback unifies stall + onError: retry the SAME track once
  (skipToQueueItem rebuilds a fresh source/HTTP — a transient blip no
  longer loses it); on exhaustion, surface via the #58 SnackBar and
  skip to the next cached track, else pause (no thrashing through
  unreachable streams).
- per-track retry budget resets when a track reaches ready+playing.
- _handlePlaybackError now delegates into the unified path.

Core playback change — device-verify on a throttled connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:14:25 -04:00
bvandeusen 1646b02ca2 refactor(flutter): drop legacy home snapshot path (#406)
The home screen renders solely from the per-item cached_home_index
path (proven on devices for many releases); the legacy snapshot stack
was dead weight.

- library_providers: remove homeProvider + _encodeHomeData +
  _albumToJson/_artistToJson/_trackToJson; drop now-unused dart:convert
  and models/home_data.dart imports
- metadata_prefetcher: re-point off homeIndexProvider/HomeIndex —
  pre-warm artistProvider from the rediscover/last-played artist
  sections (album/track tiles hydrate their own artist on render)
- live_events_dispatcher: homeProvider -> homeIndexProvider so live
  events still refresh the home screen
- db.dart: drop CachedHomeSnapshot (table class + @DriftDatabase
  entry); schemaVersion 10->11; from<3 createTable -> raw
  customStatement so the historical step compiles without the
  generated symbol; from<11 DROP TABLE cached_home_snapshot

No test references the removed symbols. db.g.dart regenerated by CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:04:43 -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 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 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 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 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 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 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 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 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 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 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 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 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 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