Commit Graph

32 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 6f20a75f9b feat(flutter): playlist cover art via deterministic /api/playlists/{id}/cover
Same fix shape as albums: drift's CachedPlaylistAdapter.toRef was
returning coverUrl: '' because the cache table doesn't persist the
server-derived URL. Set it to /api/playlists/<id>/cover in the
adapter — handleGetPlaylistCover serves the cached collage from
disk, so the URL is deterministic and the round-trip through drift
no longer drops it.

PlaylistCard + PlaylistsListScreen pass the existing queue_music
icon as ServerImage's fallback, so when the server hasn't built a
collage yet (system playlists with no tracks at build time), the
endpoint 404s and the icon shows over the slate background instead
of an empty box.
2026-05-11 20:15:38 -04:00
bvandeusen 4ede37d9ad fix(flutter): stop the cache feedback loop — playback no longer competes
The prefetcher + alwaysRefresh combination was creating a feedback
loop visible in the logs as repeated `metadataPrefetcher: warming N
albums` cycles, each kicking N parallel getAlbum fetches that then
triggered drift writes that triggered re-emits that re-ran the
prefetcher. Tap-to-play was queueing behind 14+ in-flight cache
fetches.

Three structural fixes:

1. Prefetcher hard-dedupes per session via _warmedArtists Set.
   Re-rendering a screen no longer re-fires fetches for ids we've
   already seen.

2. Prefetcher only warms artistProvider, not albumProvider. Albums
   carry track lists; pre-warming N albums fans out N parallel
   "fetch tracks" round trips for content the user may never visit.
   Artist rows are single-row lookups — cheap. Album detail loads
   on tap (still fast: server-side perf work makes it ~one round
   trip).

3. Drop alwaysRefresh from albumProvider, artistProvider,
   artistAlbumsProvider, artistTracksProvider. Each was kicking one
   silent background refresh per first cache hit. With the prefetcher
   creating many subscriptions in parallel, that meant every
   prewarmed id triggered an extra fetch even when drift was already
   populated. playlistsListProvider keeps alwaysRefresh — system
   playlists genuinely rotate UUIDs and need the catch-up. Pull-to-
   refresh remains the explicit invalidation path everywhere else.

Removed the warmAlbums calls from the library Albums tab and artist
detail album grid (the storm sources).

Net effect: cold app boot warms ~12-15 artist rows once, period.
Tapping a tile still fetches its detail on demand (one round trip,
fast). User-initiated playback isn't queued behind cache work.
2026-05-11 19:02:16 -04:00
bvandeusen 4bd069430b feat(flutter): extend metadata prefetch to library tabs + artist detail
MetadataPrefetcher gains warmAlbums(ids) / warmArtists(ids) public
methods so callers can fan out drift-cache warm-ups for whatever
collection just landed.

Wired into:
- Library Artists tab — warms first 8 artists from the page on every
  data emit (initial + paginate + refresh).
- Library Albums tab — same for first 8 albums.
- Artist detail album grid — warms first 8 albums from the artist's
  album list as soon as it loads, so tapping into any of them is a
  drift hit.

Hard-cap of 8 per call (same as the home prefetch). Set spans across
calls aren't deduped at this layer because providers themselves
short-circuit on cached values.

Also instrument the artist-detail play button: try/catch around the
artistTracksProvider read + playTracks call, snackbar on
empty-tracks or thrown-error so silent failures stop being silent.
The current behavior was an early return on tracks.isEmpty with no
visible feedback.
2026-05-11 18:52:37 -04:00
bvandeusen 22152b1ba3 feat(flutter): metadata prefetcher — pre-warm drift for likely tap targets
Across-the-board sluggishness was every cold-cache tap doing one
network round trip while the user waits. SWR helps on re-visits but
the first time you tap a tile from home you eat the latency.

MetadataPrefetcher listens to homeProvider. When /api/home returns,
it fires fire-and-forget reads on albumProvider + artistProvider for
the top-N items in each home section (recently added, rediscover,
most played, last played). Each provider read triggers the existing
cold-cache path, which writes to drift. By the time the user
actually taps a tile, it's already a drift hit and the detail
screen renders instantly.

Cap N=8 per section (covers what's visible on a typical phone
without scrolling). Set spans dedupe across sections so popular
artists don't get fetched five times. Errors are swallowed — a
failed prefetch is silent and the tile falls back to its on-tap
fetch behavior.

Wired alongside the existing audio prefetcher in app.dart's
postFrameCallback.
2026-05-11 18:44:09 -04:00
bvandeusen 9cac664679 feat(flutter): register stream-cached files in the audio cache index
Closes the gap where LockCachingAudioSource wrote files to disk but
never told AudioCacheManager about them — meaning evict() couldn't
reclaim stream-cached files when usage exceeded the cap, only
explicitly-pinned downloads.

Wire just_audio's bufferedPositionStream as the "download complete"
signal: when bufferedPosition reaches duration (with 200ms slack for
header bytes), look up the on-disk file at the LockCaching path,
read its size, and insert an audio_cache_index row via the new
AudioCacheManager.registerStreamCache(). Source defaults to
incidental so stream-cached tracks are first to be evicted under
pressure.

Dedupe via _streamCacheRegistered Set so we don't hit drift on every
~200ms buffered-position emit. Cache the application cache dir path
on first use for the same reason.

Eviction now sees the full set of files on disk; usageBytes() (which
already walks the dir) and evict() (which reads the index) are
finally consistent for stream-cached tracks. Pinned tracks keep
their existing manual-download flow unchanged.
2026-05-11 17:46:32 -04:00
bvandeusen c08f4ace80 fix(flutter): cache usage display reflects actual disk, not just index
The Storage card has been showing "0 B" for users who only stream
(never explicitly pin or download an album). usageBytes() summed
SUM(size_bytes) from audio_cache_index — but the streaming path
through audio_handler writes files via LockCachingAudioSource without
ever inserting an index row, so the index undercounts (often to
zero) for normal use.

Walk the cache directory instead. Catches everything on disk:
manually pinned tracks (registered in the index), stream-cached
tracks (LockCaching), partial downloads. Falls back gracefully when
a file is racing against concurrent writes / deletes.

Eviction still operates on the index (it needs the source/recency
metadata to pick eviction order). Stream-cached files aren't subject
to eviction today — separate problem; addressed when we wire a
download-complete hook from LockCaching back into the index.
2026-05-11 17:41:07 -04:00
bvandeusen 107abda97e fix(flutter): album/artist covers in artist detail + tighter grid
Drift cache intentionally drops cover_url (server-derived). The
adapters comment says "REST cold-cache fallback briefly shows the real
values before drift takes over" — but once drift takes over, covers
are empty forever. Fix per source:

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

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

Album grid spacing was off because childAspectRatio: 0.8 inflated
each cell taller than the AlbumCard's actual ~160dp footprint,
leaving a visible gap below every card. Switch to mainAxisExtent: 168
with explicit 8dp main/cross spacing — cells now match the card and
sit on a clean grid.
2026-05-11 08:39:41 -04:00
bvandeusen f88e82a777 fix(flutter): album/artist detail covers + cacheFirst tracing for hangs
Album detail screen rendered the cover slot as a bare slate Container
— no actual image. Wire ServerImage at /api/albums/<id>/cover (96dp,
rounded). Same for artist detail: ClipOval around ServerImage of the
artist's coverUrl, falling back to slate when empty.

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

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

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

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

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

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

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

Switch the provider to async* and seed it with an immediate
checkConnectivity() before forwarding the change stream. .future
now resolves on first read regardless of whether the OS has reported
a change yet.
2026-05-11 08:21:55 -04:00
bvandeusen a8e6e1c2f7 fix(web,flutter): PlayerBar test scoping + system-playlists revalidate
Two bundled fixes:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:22 -04:00
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00
bvandeusen 94727f40bb fix(flutter): analyze errors after Plan C provider migrations
- cache_first.dart: backtick-fence List<T> doc comment to dodge
  unintended_html_in_doc_comment.
- library_providers.dart:163: switch single-row insert to
  insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict*
  variant.
- 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>,
  not Create<Future<T>>. Wrap data emissions in Stream.value(...).
- artist_detail_screen_test: add models/album.dart import for AlbumRef
  type annotation.
- track_actions_sheet_test: drop _StubLiked extends LikedIdsController
  (notifier no longer exists post-migration); override with
  Stream.value(LikedIds(...)) instead.
- like_button_test: rollback assertion now requires drift writes via
  LikesController. Skip under _skipDrift until libsqlite3-dev lands on
  the runner image (Fable #399). Replace stale .notifier reference
  with likesControllerProvider for completeness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:04:27 -04:00
bvandeusen 4eb9935f8e feat(flutter/cache): cacheFirst helper + drift↔model adapters (#357 plan C)
Foundation for the provider migrations. cacheFirst<D, T> wraps the
drift.watch() + REST cold-cache fallback pattern: yields cached rows
when present, kicks off REST fetch + drift populate when empty +
online, yields empty when offline. REST failures swallow to empty so
callers can surface errors via toast.

adapters.dart adds CachedX → XRef extension methods + reverse
XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters
accept some loss of server-derived fields (coverUrl, streamUrl,
ownerUsername) — UI already handles empty values; cold-cache fallback
briefly shows the real values before drift takes over.

cache_first_test covers all 4 branches (non-empty, empty+online,
empty+offline, REST failure). adapters_test covers basic round-trips.
Both safe to run on CI runner — no drift open required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:29 -04:00
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00
bvandeusen 536350380f feat(flutter/cache): Prefetcher — queue-ahead pre-download window
Listens to mediaItemProvider + cacheSettingsProvider. On track change
(or settings change), computes the [currentIdx, currentIdx + N] window
in the queue and pins each uncached track via AudioCacheManager with
source: autoPrefetch.

Window N comes from cacheSettingsProvider.prefetchWindow (default 5,
configurable in Settings → Storage card).

Smoke-test only at the unit level — real coverage via on-device pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:19:01 -04:00
bvandeusen ab58f3ffcb feat(flutter/cache): SyncController — token-based delta sync
Drives /api/library/sync against drift:
  - reads cursor from SyncMetadata (default 0 = full snapshot)
  - 204 → just bump lastSyncAt, return zeroes
  - 410 → wipe all cached entities + retry from cursor=0
  - 200 → apply upserts + deletes per entity type, advance cursor

Handles all 8 entity types (artist/album/track + like_track/like_album/
like_artist + playlist/playlist_track) for both upsert and delete paths.
Composite-key entities use the "<a>:<b>" string format the server emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:18:15 -04:00
bvandeusen 16669a0365 feat(flutter/cache): AudioCacheManager — pin/unpin/evict + tiered LRU
API:
  - isCached(trackId), pathFor(trackId)
  - pin(trackId, source) — dio.download → drift index entry
  - unpin(trackId), clearAll()
  - usageBytes(), evict(targetBytes)

Eviction order: incidental → autoPrefetch → autoPlaylist → autoLiked.
'manual' never evicts via cap-driven eviction; only clearAll() removes
operator-pinned tracks.

Two providers:
  - appDbProvider — singleton AppDb per run
  - audioCacheManagerProvider — wraps the AppDb + dioProvider

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:17:14 -04:00
bvandeusen 2bcbb53c49 feat(flutter/cache): connectivityProvider + cacheSettingsProvider
connectivityProvider — StreamProvider<bool>; true when any connectivity
result is non-none. No Wi-Fi gate per operator decision.

cacheSettingsProvider — AsyncNotifier<CacheSettings> persisting
capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage.
Defaults: 5 GB cap, 5-track prefetch, cache liked = on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:15:17 -04:00
bvandeusen 326b7008a9 feat(flutter/cache): drift schema + new deps + CI codegen step (#357 plan B)
Adds the offline-mode foundation:
- pubspec deps: drift, drift_flutter, sqlite3_flutter_libs,
  connectivity_plus + drift_dev/build_runner (dev)
- lib/cache/db.dart with 8 tables: CachedArtists/Albums/Tracks/Likes/
  Playlists/PlaylistTracks + AudioCacheIndex + SyncMetadata
- *.g.dart added to .gitignore (build_runner regenerates per build)
- CI workflow gains a 'Codegen (drift)' step between pub get and
  analyze so the generated symbols exist when analyze runs

CacheSource enum drives tiered LRU eviction (manual/autoLiked/
autoPlaylist/autoPrefetch/incidental). Subsequent commits add the
sync controller, audio cache manager, prefetcher, settings UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:07:34 -04:00