Commit Graph

230 Commits

Author SHA1 Message Date
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
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 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 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 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 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 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