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.
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.
Slice 3 of the smooth-loading pass. The three _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider entries on the Library
screen migrate from FutureProvider+REST to StreamProvider+cacheFirst.
Reads now flow from cached_likes joined against the metadata tables
SyncController already keeps fresh; LikesController's optimistic drift
write makes toggling a like re-emit these streams instantly without a
REST round-trip. Cold-cache fallback hits /api/likes/* when drift is
empty (fresh install pre-first-sync). SWR refresh on each visit catches
likes from other devices that haven't propagated via library_changes
yet.
The original FutureProvider versions fetched the first 50 rows. Drift
returns everything cached_likes knows about — for typical libraries
that's the full liked list. Pagination can come back when liked lists
are big enough to matter.
Like/unlike SSE invalidation paths preserved so cross-device updates
still feel real-time, even when the sender's library_changes hasn't
landed here yet.
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.
Slice 1 of the cover-caching pass. The previous Image.network /
NetworkImage path only cached covers in memory, so a scroll-off + scroll-
back or an app restart re-downloaded every tile from the server. Swap
to cached_network_image so bytes land on disk (path_provider temp dir,
URL-keyed) and survive both.
Sites migrated:
- ServerImage (all /api/*/cover usage — home grid, library, playlist,
artist/album detail headers)
- DiscoverScreen Lidarr suggestion thumbnails
- PlayerBar mini cover (HTTPS branch; file:// branch unchanged since
AlbumCoverCache files are already on disk)
Auth header forwarding preserved via httpHeaders. Fade-in disabled so
populated grids paint instantly on cache hit.
Slice 2 (pre-warm during sync) builds on this same cache manager.
Drops the staleness gate from 1h to 1m and adds a Timer.periodic that
fires recheckIfStale every minute while the app is foregrounded. Net
effect: ~1 check per minute of active use, ~60 KB/hr data — trivially
affordable for the value of faster recovery when min_client_version
bumps server-side.
Timer is paired with the lifecycle observer: started in initState +
on resume, stopped in dispose + on pause/inactive/hidden/detached so
backgrounded apps don't burn battery on probes the user can't see.
Staleness gate still wraps the call so concurrent triggers (timer +
resume firing close together) dedupe to one network call. Manual
"Check now" still bypasses the gate via recheck().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-start spinner was up to ~38s on slow / remote connections
because VersionGate blocked the entire ShellRoute on /healthz, with
the default dio's 8s connect + 30s receive timeouts. The /healthz
server handler itself is fine (microsecond JSON encode); the blocker
was client-side. Three issues fixed in one pass:
1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget
that always renders its child and just activates the version
check controller on mount. The "you're too old" experience moves
from a full-screen hard-block (_TooOldScreen, deleted) to a soft
banner above the AppBar that lets the user keep playing cached
content while they update.
2. 1h-throttled background check. New VersionCheckController
(AsyncNotifier) hydrates from a secure-storage cache on boot,
returning the cached result instantly. If the cache is missing
or >1h old, fires a background recheck. AppLifecycleState.resumed
triggers recheckIfStale so foregrounding after >1h re-checks
without per-frame hammering. "Check now" button on the banner
bypasses the staleness gate so dev iteration (push new APK, want
to see banner clear) doesn't wait an hour.
3. Bounded health-check dio. The /healthz request uses a dedicated
dio with connectTimeout: 3s + receiveTimeout: 2s rather than the
default 8s / 30s. Health probes should fail fast — if the server
can't ack in 5s, the user has bigger problems than a stale
min_client_version and the cached value remains in effect.
Cache keys live alongside the existing tz cadence cache in
flutter_secure_storage (kResult + kAtMs). On any network error or
parse failure, _runCheck soft-fails without bumping the timestamp,
so the next staleness check will retry.
VersionTooOldBanner renders in _ShellWithPlayerBar's Column above
the existing UpdateBanner — the two coexist when both apply
(server rejects you AND an APK is queued).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last deferred follow-up from #357. The library_changes
table is the append-only change log that drives /api/library/sync's
delta semantics — every mutation (scanner upsert, like, playlist
edit, track delete) writes one row. Without a retention policy the
table grows unbounded; the original migration (0025) called out the
follow-up explicitly.
New goroutine: sync.Compactor runs daily, deletes rows where
changed_at < now - 30 days. Logs a row count when non-zero so
operators can see compaction activity in the journal. First tick
fires on startup so a process that hasn't been compacted in a
while catches up immediately.
30-day retention matches the offline-mode spec
(docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md).
Clients with a cursor older than that hit the existing 410 fallback
path and resync from scratch.
Imported as syncpkg in main.go to follow the existing convention
(see internal/library/scanner.go).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
#401 introduced player.current?.id reads into TrackRow.svelte and
PlaylistTrackRow.svelte, breaking 26 test cases across 6 files whose
existing vi.mock('$lib/player/store.svelte', ...) blocks only stubbed
the functions used by the original components.
Added player: { current: undefined } to each affected mock — keeps
the existing function spies and lets the new isCurrent derivation
read a defined (false-y) player.current without blowing up.
Only updated the 6 files that failed; 16 other player-store mocks
exist across the suite but their tests don't render Track/Playlist
rows so the derivation never fires. Future tests that render those
rows will need the same stub (visible at the failure point with a
clear error message).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#392's dispatcher only invalidates publicly-importable providers
(myQuarantine + home). Screen-scoped providers (file-private in their
feature folders) get their own ref.listen(liveEventsProvider, ...) so
they go live without needing back-edge dependencies from /shared.
Five screens wired:
- library_screen.dart _LikedTab — invalidates _likedTracksProvider /
_likedAlbumsProvider / _likedArtistsProvider on any of the six
track/album/artist like/unlike kinds.
- playlist_detail_screen.dart — invalidates playlistDetailProvider(id)
on playlist.updated / playlist.tracks_changed matching the visible
playlist_id. On playlist.deleted matching the visible id, pops back
so the user isn't left staring at a gone playlist.
- admin_requests_screen.dart — invalidates adminRequestsProvider on
request.status_changed (covers user create/cancel + admin
approve/reject + reconciler complete).
- admin_quarantine_screen.dart — invalidates adminQuarantineProvider
on any quarantine.* event (flag from a user / admin resolve / file
delete / lidarr delete).
- requests_screen.dart (own requests) — invalidates myRequestsProvider
on request.status_changed. Server-side events are user-scoped via
publishRequestStatusChanged's row.UserID, so admin actions on
someone else's request route to the right stream.
History tab is NOT wired (no server-side play.scrobbled event yet —
documented in #402 body as deferred until that event ships).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#392 shipped track.liked / track.unliked but skipped the album +
artist symmetric pairs. Closes that gap so the Flutter Liked tab's
albums and artists sub-lists can listen for changes the same way
the tracks sub-list will (#402 wire-up lands next commit).
publishLikeEvent already handles the entity_type dispatch; the four
handler call sites just need the new lines.
For #402 follow-up to #392.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-platform consistency: when a track is currently playing, its row
on the album / liked / history / search / playlist detail surfaces
gets the same accent-border + bg-lift treatment that QueueTrackRow
applies on the queue panel. Closes the inconsistency caught during
the #375 DRY audit (the queue row had a "you are here" indicator;
other track-row surfaces did not).
Web — TrackRow.svelte + PlaylistTrackRow.svelte:
isCurrent = player.current?.id === track.id
→ border-l-2 border-l-accent bg-surface-hover when true.
PlaylistTrackRow additionally gates on !isUnavailable so deleted
rows never match a phantom playing id.
Flutter — TrackRow + _PlaylistTrackRow:
TrackRow becomes a ConsumerWidget, watches mediaItemProvider, and
wraps its InkWell in a Container whose BoxDecoration carries the
fs.iron background + 2px fs.accent left border when current. Title
text also shifts to fs.accent and FontWeight.w500. Same pattern for
_PlaylistTrackRow.
No "Now playing" pill on these surfaces — the player bar already
names the track, so the accent band alone reads as enough cue.
For #401 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues caught by flutter analyze --fatal-infos:
- dart:ui import in album_color_extractor.dart was redundant because
flutter/painting re-exports the Color it provides. Dropped.
- valueOrNull isn't on AsyncValue in this Riverpod version (the
AsyncValue<Color?> nesting may also have confused the resolver).
Switched to asData?.value which always returns the wrapped value
on AsyncData and null on Loading/Error.
(palette_generator's "discontinued" warning is non-fatal informational
in pub; CI didn't fail on it. The package still works; alternative
swaps deferred until it actually breaks.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the four locked items (1, 2, 4); item 6 (swipe-tabs) stays
deferred until server-side lyrics ingestion exists.
1. Dominant-color gradient backdrop. New album_color_extractor.dart
wraps the existing AlbumCoverCache: extracts the dominant color
via PaletteGenerator over the local file, caches in-memory keyed
by album_id. Top 55% of the screen carries the color (0.55 alpha
→ fs.obsidian) so controls below stay legible. AnimatedContainer
tweens the gradient across track changes.
2. Hero transition for cover art (mini bar → full screen). Stable
kPlayerCoverHeroTag (not media.id keyed) so the transition works
regardless of what's playing and isn't racy if media swaps mid-tap.
flightShuttleBuilder renders the destination's Hero widget for the
whole flight, which reads as a clean grow rather than a swap.
4. Crossfade on track change. AnimatedSwitcher around the album art,
title, and artist+album text block, all keyed by media.id so the
switcher fades between old and new on each track-change rebuild.
Pairs with the AnimatedContainer gradient so the whole "what's
playing" zone changes in lockstep.
palette_generator: ^0.3.3 added.
For #396 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlaylistTrack.streamUrl is String? (nullable when track is unavailable
post-delete); TrackRef.streamUrl is required String. flutter analyze
caught the mismatch. Coalesce to empty string for unavailable rows —
they're already filtered out by the trackId != null check earlier in
the loop, so this branch is effectively unreachable but keeps the
type-checker happy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AlbumCard / ArtistCard / PlaylistCard gain a 44dp circular play
button overlaid bottom-right of the cover art. Mirrors the
hover-revealed .play-overlay on the web cards; always visible
because hover is not a real interaction on touch.
Per-card semantics match the web:
- AlbumCard: fetches /api/albums/{id}, starts playback from track 0.
- ArtistCard: fetches /api/artists/{id}/tracks, Fisher-Yates shuffles,
plays from index 0 (matches web's playQueue(shuffle(tracks), 0)).
- PlaylistCard: fetches /api/playlists/{id}, materializes available
rows into TrackRef, plays from index 0. Disabled state when
trackCount == 0 — semi-transparent button, taps ignored.
Shared PlayCircleButton widget manages loading state (spinner during
fetch) so each card's onPressed can stay async without re-entrancy
guards. Cards become ConsumerWidget so they can reach the
playerActionsProvider + relevant API providers; constructor surface
unchanged so existing call sites (artist_detail, library_screen,
home_screen) keep working.
CompactTrackCard unchanged — its tap already plays-from-here.
For #393 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter client posts FlutterTimezone.getLocalTimezone() to
PUT /api/me/timezone on every setSession (login / register success)
and on every AuthController.build (app cold-start with valid
session), when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in flutter_secure_storage so it survives app
restarts.
Failures swallowed: the server's UTC default + last-known value
keep the scheduler functioning until the next attempt.
Completes the client side of #392 Half B (per-user timezone
scheduling).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web client posts Intl.DateTimeFormat().resolvedOptions().timeZone to
PUT /api/me/timezone after every successful login, register, and
bootstrap when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in localStorage keeps the server stateless on the
"is this stale?" check.
Failures swallowed: the server's UTC default + last-known value keep
the scheduler functioning until the next successful attempt. SSR-
safe via the typeof window guard.
For #392 Half B. Companion Flutter change in next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gocron v2's WithLocation is a SchedulerOption (process-wide), not a
JobOption — there's no per-job location knob. To get per-user
timezones we use a cron expression with the CRON_TZ= prefix, which
the underlying robfig/cron parser honors:
CRON_TZ=America/New_York 0 3 * * *
Fires at 03:00 in the named zone every day. Same DST-correctness as
the original WithLocation approach.
Fall-back to UTC moves inline (was validateTimezoneOrUTC); kept the
helper because the test file still exercises it.
Caught by go vet on CI after slice 2 pushed:
"cannot use gocron.WithLocation(loc) (value of type
gocron.SchedulerOption) as gocron.JobOption value"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the server side of #392 Half B.
PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.
POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.
system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the per-user daily-at-03:00-local scheduler for #392 Half B.
Uses github.com/go-co-op/gocron/v2 with WithLocation(userTZ) for
each user's job. Hourly reconciliation pass keeps the in-memory
job set in sync with the active-users query.
Start sequence: clear stale in_flight rows; register a daily job
for every active user at their stored timezone; fire a one-shot
runOnce to catch up missed schedules during downtime; start gocron.
Stop drains and shuts down the gocron loop.
Refresh(ctx, userID) removes the user's existing job (if any) and
registers a fresh one at their current timezone — wired into the
PUT /api/me/timezone handler and new-user registration in the next
commit.
Server struct gains PlaylistScheduler; main.go constructs it after
the eventbus and threads it through to api.Mount. The existing
StartSystemPlaylistCron call stays for one more commit so we don't
have a window where no scheduler is running; Task 3 deletes it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema + endpoint scaffolding for #392 Half B (per-user timezone
scheduling). Adds two columns to the users table:
- timezone text NOT NULL DEFAULT 'UTC' (IANA name)
- timezone_updated_at timestamptz (nullable; populated on each PUT)
PUT /api/me/timezone validates the IANA value via time.LoadLocation
and writes the row. No scheduler integration yet — the scheduler
struct lands in the next commit and the handler-side Refresh call
in the commit after.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five diversity mechanics — applied to both For-You and Songs-Like-X.
1. For-You seed rotates daily across the user's top-5 most-played
tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
len(seeds) so today's mix uses an entirely different similarity
pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
by userIDHash(user, day) rather than the no-op, so near-tied
candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
playlist comes from the tail now (daily-deterministic via
tieBreakHash), giving the user substantially different content
while a 12-track anchor of strong similarity matches keeps the
mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
played artists. pickSeedArtistsForDay applies a userIDHash-seeded
Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
parameter so the RNG can be seeded per-user; existing call sites
updated; noopRNG removed.
Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.
PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.
For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For-You + Songs-Like-X seed selection moves to Go-side daily rotation
(next commit). The SQL change just widens the candidate pool: top-5
played tracks instead of single top played track; top-5 artists
instead of top-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 4 — completes the #392 hybrid live-refresh loop.
live_events_provider.dart subscribes to /api/events/stream via dio's
streaming response mode, parses SSE frames (kind + JSON data + UserID
scope), and exposes them as a Riverpod StreamProvider. Heartbeat
comments are silently dropped; malformed JSON frames are skipped. The
provider auto-rebuilds when auth state changes (token rotation,
sign-out → sign-in), so reconnect is implicit.
live_events_dispatcher.dart listens to the stream and invalidates the
small set of publicly-importable providers we know about:
- myQuarantineProvider + homeProvider on any quarantine.* event
- homeProvider on any playlist.* event (Home renders the Playlists row)
Screen-private providers (library_screen.dart's _liked* /
_libraryAlbums / _history, admin screens, etc.) opt in to live-refresh
by themselves listening to liveEventsProvider in follow-up commits;
the dispatcher stays small and avoids back-edge dependencies on every
feature folder.
The dispatcher also installs an AppLifecycleState observer for
resume-time defensive invalidation. SSE will catch up on its own when
the app returns from background, but the invalidate flushes any stale
data immediately so the first frame back is fresh.
app.dart wires the dispatcher into the post-first-frame callback
alongside the other startup activations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hand-unrolled byte-pair version in reconciler.go tripped gofmt -s
and goimports. Replaced with the same loop-based formatter that
internal/library/eventbus.go uses — same behaviour, fewer lines, lint
clean. Two copies of the helper still exist (one per package) to keep
the no-back-edge property for both internal/library and
internal/lidarrrequests, but they're now identical and the duplication
is tiny.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3c — completes the server-side producer set.
Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.
Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).
This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3b — extends event publishing to background workers.
When the reconciler matches an approved Lidarr request against the
library and flips it to 'completed', it now publishes a
request.status_changed event scoped to the original requester so their
/requests page invalidates without polling. Three call sites — one per
kind (artist / album / track) — capture the completed row instead of
discarding it so the publish has the user_id.
Bus plumbing: the reconciler runs in cmd/minstrel/main.go which
constructs services before server.New is called. Moved bus
construction into main; the Server struct gained a Bus field that
Router() reads, falling back to a fresh local instance when nil
(test contexts). Result: one bus per process shared by every
publisher and the SSE subscriber endpoint.
reconciler.go inlines a uuid->string helper rather than reaching into
internal/api for one — avoids a back-edge dependency.
Test compat: 9 NewReconciler call sites in reconciler_integration_test.go
get `nil` for the new bus param. The reconciler's publishCompleted helper
is a no-op when the bus is nil so tests that don't care about events
keep passing.
Scanner producer (scan.progress) deferred to slice 3c — needs more
thought about which lifecycle transitions warrant events vs. flood the
stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3a — extends the producer set onto the SSE bus.
quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
clients invalidate their Hidden tab and admins' clients invalidate
their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
actions): broadcast because a single admin action can affect every
user who'd flagged that track.
playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
Reorder. Owner-scoped. Single kind for all three mutations because
the client invalidation logic is identical (refetch the detail).
Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 2 of #392 — wires the first producers onto the bus that slice 1
built. After this commit, an SSE subscriber sees real events fire:
- track.liked / track.unliked when the user toggles the heart on a track
(handleLikeTrack / handleUnlikeTrack). Album + artist like events
intentionally deferred — they're symmetric trivial follow-ups but the
operator's primary like surface is tracks.
- request.status_changed when a Lidarr request is created, cancelled,
approved, or rejected. Auto-approve will fire twice (pending then
approved) in rapid succession, which is semantically correct; client
invalidation handles that fine.
Events are user-scoped via row.UserID so admin approve/reject route to
the requester, not the admin acting. Helpers live in events_publish.go
so the wire shape (kind names, payload keys) stays in one place — future
producers in slice 3 reuse the same pattern.
events_publish.go is no-op when h.eventbus is nil so tests that
construct handlers without a bus continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go vet caught a missed test caller of api.Mount after slice 1's signature
change added the *eventbus.Bus parameter. Pass eventbus.New() in the test
— the TestRoutesRegisteredInMount test only walks the route table for
existence, never publishes or subscribes, so a throwaway bus is fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1 of the #392 hybrid live-refresh work. Ships the in-process pub/sub
bus and the SSE subscriber endpoint; no producers wired yet, so the stream
emits only heartbeats today. Verifiable in isolation by curl-ing the
endpoint with a valid Bearer token — the connection opens, ": heartbeat"
lines arrive every 15s, the connection closes cleanly on client disconnect.
eventbus.Bus is a small fan-out broadcaster: subscribers register through
Subscribe (returns a receive channel + an unsubscribe closure), writers
call Publish, and the bus drops events for any subscriber whose buffer is
full rather than blocking the writer. No persistence — clients are
expected to resync via normal /api/* fetches on (re)connect.
The SSE handler emits an initial ": connected" comment so the client sees
the connection open immediately, then forwards events whose UserID matches
the authenticated user (or is empty for broadcast). Heartbeat comments
keep proxy connections alive. Context cancellation cleanly tears down the
subscription on client disconnect.
Producers (likes, request status, quarantine, scanner, playlist mutations)
land in subsequent slices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small parity gaps in _HiddenTab vs the web /library/hidden page:
- No unhide affordance — once a track was flagged via the kebab, the only
way to reverse it was to find the track in another surface and toggle
via the kebab again. Added an Icons.restore IconButton on each tile that
calls myQuarantineProvider.notifier.unflag(trackId) (the optimistic
remove-with-rollback already lived on the notifier).
- No album cover art — added a 56px ServerImage thumb matching the web
page's 14×14 thumb, with the same fs.slate fallback compact_track_card
uses for missing covers.
- No relative timestamp — appended _relativeTime(row.createdAt) next to
the reason pill so the user can tell "I hid this 3d ago" at a glance.
Also collapsed the duplicate provider: _HiddenTab was watching a local
FutureProvider that didn't see flag/unflag mutations, while the kebab's
HideTrackSheet flow goes through the canonical myQuarantineProvider
(AsyncNotifier). Switched _HiddenTab to watch myQuarantineProvider so
flag-from-anywhere and unhide-from-the-tab stay in sync. The local
_quarantineProvider was deleted; one source of truth now.
Caught during the #375 DRY audit cross-check against the #356 inventory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DRY pass audit (#375) found two inline mock patterns repeated across the
vitest suite: a default empty-playlists mock for $lib/api/playlists (4 exact
copies in menu/card tests) and an api-client spy mock for $lib/api/client
(9 callers split between get-only and full-RESTy shapes — unified into one
helper that always returns all four verb spies).
Mirrors the existing test-utils/mocks/likes.ts and test-utils/mocks/quarantine.ts
convention. Tests with intentionally divergent shapes (AddToPlaylistMenu's
richer createPlaylist payload, PlaylistCard's getPlaylist, route-specific
mocks, events.svelte's specific resolved value) stay inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cross-user bucket query combined SELECT DISTINCT with ORDER BY md5(...),
which Postgres rejects at plan time (SQLSTATE 42P10). buildDiscoverCandidates
returned that error on first bucket failure, so the whole Discover playlist
was skipped every nightly run — even though the random bucket pool was
healthy. Switched to GROUP BY so the md5 ordering expression no longer needs
to appear in the select list, and hardened the function so future single-
bucket failures degrade gracefully via slot redistribution instead of taking
out the whole playlist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated issues, batched.
Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.
Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
}
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.
Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.
Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
Same root cause as the /queue duplicate-page-key crash: /now-playing
is a top-level route (lives outside the ShellRoute), but
/artists/:id and /albums/:id are shell-children. Pushing a shell-
child from a top-level route makes go_router attempt to mount a
second ShellRoute on top of the active one, leaving navigation in a
broken state. The mini player works because it's already inside the
shell.
Add an optional onBeforeNavigate callback to TrackActionsSheet
(forwarded through TrackActionsButton). When set, fires after
sheet.pop() and before context.push() of the destination route.
Wire the full player's TrackActionsButton with onBeforeNavigate:
() => Navigator.of(context).maybePop() so /now-playing dismisses
itself before the detail route is pushed. Result: clean navigation
into the destination, mini player visible underneath as expected.
Mini player keeps the default (no callback) since it's already in
the shell.
Server has been generating system_variant='discover' playlists since
M6a (internal/playlists/system.go and POST
/api/playlists/system/discover/refresh) but neither client surfaced
it. The Flutter home filtered system playlists by exact match on
'for_you' and 'songs_like_artist', dropping Discover. The web home
did the same. Tiles were generated server-side and silently
discarded by both clients.
Add a Discover slot to the playlists row in both:
- Flutter: 5-slot row now (For You, Discover, 3× Songs-like) with
matching placeholder state machine.
- Web: same shape, same placeholderVariant() call.
When the engine has built it, the real playlist tile renders;
otherwise a placeholder with the same building/pending/failed
status semantics as the other system tiles.
The lazy source-build commit (1ddde12) introduced a race: when the
user taps play on a new track while a previous queue's
_fillRemainingSources is still working, the stale fill keeps
calling _player.addAudioSource() on the new player state — appending
old-playlist tracks into the new queue and confusing the player into
the "locked to one song" symptom.
Fix: queue-generation counter. setQueueFromTracks bumps
_queueGeneration first thing; the background fill captures its gen
at start and aborts before any further player mutation if a newer
queue has taken over. The previous play() never gets to mutate the
new player state.
Also resets _suppressIndexUpdates at the start of every
setQueueFromTracks (defensive — covers the case where a prior
backward-fill bailed on its gen check before reaching `finally`)
and only releases the flag in finally if we're still the active
gen.
Symptoms this should resolve:
- "locked to one song" after rapid play taps
- Late `play() returned 73189ms` lines indicating a previous
hung play() call finally resolving and stepping on current state
- Player stuck in odd processingState after queue swaps
Same pattern as album + artist detail. PlaylistDetailScreen accepts
optional Playlist seed via go_router extra. While
playlistDetailProvider is still resolving, render the header
(description if any + track count) plus a "loading tracks…" inline
spinner instead of an opaque full-screen CircularProgressIndicator.
The body fills in when tracks arrive via drift watch re-emit.
Routing reads s.extra as Playlist. PlaylistCard +
PlaylistsListScreen pass the playlist via extra. Surfaces with no
seed available (deep links etc.) fall back to the original full-
screen spinner.
Track row rendering already uses ListView.builder so visible row
count was never the bottleneck — the wait was for the detail fetch
itself. Header-on-tap is the win that makes the screen feel snappy.
Two unrelated wins as a single batch.
Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.
New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.
Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.
Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
change rarely (re-scan, MBID enrichment); a day of cache is safe
and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
recompute when contents change; short enough for edits to feel
fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
are immutable for a given id (scanner re-indexes by file_path; new
files get new ids). LockCachingAudioSource on the Flutter side
already disk-caches, but proper headers let it skip even the
conditional 304 on repeat plays.
Logs were spitting "RenderFlex overflowed by 1.00 pixels on the
bottom" from album_card.dart whenever the library Albums tab or
artist detail album grid rendered. Cell height was computed as
cover + gap + title + artist + 4px slack, which assumes pixel-perfect
14sp/12sp line heights — Flutter's actual rendering with ascent/
descent + line-height multipliers wants one more pixel.
Bump slack from 4 to 8 in both grids. AlbumCard layout unchanged;
the warnings stop.
Otherwise the log shape is healthy now: prefetcher fires once per
library page emit (expected, one batch per pagination), cache misses
fetch sequentially with clean drift-write → re-emit cycles, and
album taps are single-round-trip cold-fetches followed by drift hits.
No more cycles of duplicate fetches.
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.
Tap→audio measured at ~370ms — that path's fast. Real symptom: a few
seconds of audio, then silence with no event surfaced to Flutter.
ExoPlayer is failing/completing somewhere and we have no log to act
on.
Three changes, all log-only:
- Add onError to playbackEventStream so stream failures (404, range-
request bugs, decoder errors, network drops) print instead of
silently halting playback.
- Subscribe to playerStateStream and log playing + processingState
on every transition. Silent stops will now show as a state shift
to completed / idle / buffering with no resumption.
- Subscribe to processingStateStream separately to catch fine-grained
state transitions ExoPlayer reports between source advances.
After hot-restart, tap-then-go-quiet should produce a sequence we
can read — most likely either "processingState=completed" partway
through (server returning premature EOS or wrong Content-Length) or
a thrown error from ExoPlayer's source-loading path.
CI fixes:
- artist_detail_screen.dart: drop unnecessary foundation import (debugPrint
comes from material) and unused metadata_prefetcher import.
Playback timing visibility (so we can stop guessing where the lag
lives):
- playTracks now logs serverUrl / token / configure / setQueue /
play() returned, each stage in milliseconds. The next time you
tap play, we'll see exactly where the seconds go.
- setQueueFromTracks adds two more measurements: total source-build
time across all tracks, and setAudioSources duration.
Small concrete win:
- audio_handler caches the application cache dir path on first use
(already cached in _maybeRegisterStreamCache; now also used in
_buildAudioSource for the LockCachingAudioSource path). One less
platform channel hit per track on cache-miss queue builds.
Once we see real numbers we can decide whether the fix is to build
sources lazily (initial source first → play → background-add the
rest), pre-warm the audio handler at app start so playTracks skips
serverUrl + token reads entirely, or something else.
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.
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.
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.
Detail screen showed empty rows for system playlists because the
fetch batch wrote cachedPlaylists, cachedPlaylistTracks (positions),
cachedArtists, and cachedAlbums — but never cachedTracks themselves.
The detail screen's LEFT OUTER JOIN cachedTracks then returned null
on every row, so trackId was null and titles came back empty.
PlaylistTrack on the wire carries enough to populate cachedTracks
(id, title, albumId, artistId, durationSec). Adds a third dedup map
in fetchAndPopulate, batched with the existing artist + album writes.
track_number / disc_number aren't on the wire so they default to 0;
the detail screen doesn't surface them.
Reusing cachedTracks across albumProvider + playlistDetailProvider
also means tapping a playlist's track to play it now finds the row
in drift instead of triggering another fetch.
The 404 on tapping the For-You tile traced to stale drift rows.
BuildSystemPlaylists rotates UUIDs on every rebuild, so old For-You
/ Songs-Like ids accumulate in cachedPlaylists. The list provider's
fetchAndPopulate was only doing insertOrReplace, which adds new rows
but never removes the obsolete ones — so the home tile renders 8
playlists when the server only knows 4, and 4 of those tiles 404 on
tap.
Two fixes:
playlistsListProvider.fetchAndPopulate now reconciles. After
fetching the fresh list, deleteWhere any user-owned drift row whose
id isn't in the fresh response, then upsert the fresh set in the
same batch. Public-from-others rows are left alone — they're not
keyed by ownership and we don't want to drop someone else's public
playlist just because the current user's response didn't enumerate
it. Operates inside the existing batch so it's atomic.
playlistDetailProvider.fetchAndPopulate now treats a DioException
404 as "this row is stale": delete the cachedPlaylists + any
cachedPlaylistTracks rows for this id, return false so the UI yields
emptyDetail. The next render of the home row sees the row gone and
the tile disappears, completing the cleanup.
Side note: every system-playlist rebuild discards drift rows for the
just-evicted UUIDs and writes the new ones. That cycle's been
silently churning since system playlists shipped — this is the
first time the cleanup actually runs.
handleGetHome itself is well-architected (5 sections in parallel via
goroutines, latency-bound by the slowest single query). The cold-
start lag is two of those queries doing wider scans than necessary.
ListLastPlayedArtistsForUser was iterating FROM artists a with a
LATERAL play_events join per row — O(total_artists in library) plan
even for users who've only played a handful. Inverted: aggregate the
user's plays by artist_id first via the play_events → tracks join
(uses play_events_user_track_idx + tracks pkey), then attach the
artist row and lateral cover/count subqueries only for the artists
that actually appear. Cost now bounded by play history, not library
size.
ListMostPlayedTracksForUser was joining tracks/albums/artists for
every play_event row before grouping — O(total plays) work for
joins. Pre-aggregated play_events into a CTE keyed by track_id +
count(*), then joined to tracks/albums/artists only for the
distinct-tracks survivors. Order-by uses the pre-computed count.
No handler or generated-Go signature changes — both queries return
the same rowset shape, just much faster on libraries where total
artists/plays >> distinct-played-artists/distinct-played-tracks.
Two new sqlc queries replace three sequential per-album round trips
that were dominating detail-screen latency.
GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then
GetArtistByID — separate round trips for one logical lookup. The new
query joins albums + artists with sqlc.embed and returns both in one
SELECT. Detail-page DB cost: 3 trips → 2.
ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the
artist's album list, then issuing one CountTracksByAlbum per album to
populate track_count. On a 30-album artist that's 32 sequential
queries — each ~5ms over a local DB, ~30ms over a remote one. The
new query embeds the album row + a correlated count(*) subquery, so
every album's track count comes back in one SELECT regardless of
album count. Detail-page DB cost: 1 + N → 1 + 1.
Together these account for the bulk of cold-cache navigation latency
on the Flutter client. Combined with the existing SWR + nav
hydration on the client side, detail screens should render their
header instantly and the body within one round trip instead of
N+constant.
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.
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.
Tapping the queue button from /now-playing crashed with
NavigatorState._debugCheckDuplicatedPageKeys. Root cause: when I
moved /now-playing out of the ShellRoute earlier, /queue was left
inside the shell. Pushing /queue while /now-playing was on top
made go_router try to mount a second ShellRoute instance under the
existing one — both shells got the same page key.
Move /queue out of the ShellRoute too, sibling to /now-playing.
Shell stays mounted (with whatever child it had) underneath both
top-level routes; pop from /queue returns to /now-playing or the
shell's previous child as appropriate.
Move shuffle / repeat / queue out from below the play controls and
combine with the like + kebab into a single row sitting just above
the seek bar. Title row drops back to title-only (truly centered now,
no Stack needed since nothing competes for the row's right edge).
Layout order is now: art → title → artist → album → [shuffle, repeat,
queue, like, kebab] → seek → prev/play/next.
The "queue" icon in the top-right of the AppBar stays for now —
redundant with the new row but matches what users have already
muscle-memoried for opening the queue from any player state.
Both providers were FutureProvider<Paged<T>> returning a single page
of 50, so once the user scrolled past 50 items the list just ended.
Replace with AsyncNotifier<Paged<T>> that exposes loadMore(): fetches
the next page using items.length as the offset and appends to the
existing list. Idempotent guard via _loadingMore flag — concurrent
scroll events near the bottom collapse to a single fetch.
Both tabs now wrap the GridView in NotificationListener<ScrollNotification>
that fires loadMore() when within 800px of maxScrollExtent. The
lookahead is enough that the next page lands before the user hits
the visible bottom on a typical phone scroll.
Pull-to-refresh updated to invalidate + re-await the provider so a
manual refresh always starts from the first page.
Update installs were failing at the system scanner step because every
release APK was signed with a different signature: build.gradle.kts
fell back to signingConfigs.getByName("debug"), and the debug
keystore is generated per-machine — so every CI runner had a fresh
random key. Android refuses to upgrade an installed app when the
signature doesn't match, hence the "App not installed" failure
after the scan.
Two-part fix:
1. build.gradle.kts now defines a real "release" signing config when
ANDROID_KEYSTORE_PATH is exported (with the matching password +
alias env vars). Without those, falls through to the debug
keystore so local `flutter run --release` still works.
2. flutter.yml decodes ANDROID_KEYSTORE_B64 (CI secret, base64 of the
real release keystore) into a tmp path before the release-APK
build step, then exports ANDROID_KEYSTORE_PATH + the three
password/alias secrets as env vars. CI now hard-errors if the
keystore secret is missing on a tagged build — we don't want
another silent debug-keystore release.
OPERATOR ACTION REQUIRED before the next tag:
1. Generate a release keystore (one-time):
keytool -genkey -v -keystore minstrel-release.keystore \
-alias minstrel -keyalg RSA -keysize 2048 -validity 10000
2. Base64 it:
base64 -w0 minstrel-release.keystore > keystore.b64
3. In Forgejo, add four repo secrets:
ANDROID_KEYSTORE_B64 = contents of keystore.b64
ANDROID_KEY_ALIAS = minstrel
ANDROID_STORE_PASSWORD = the password you set
ANDROID_KEY_PASSWORD = same password (or different alias pwd)
4. Keep minstrel-release.keystore offline and backed up — losing it
means you can never sign an upgrade for any existing install.
5. Existing installs from prior debug-keyed releases must be
uninstalled before installing the first key-signed release. This
is a one-time transition; future tagged builds will upgrade
cleanly.
- artist_detail_screen.dart missed an `import '../models/artist.dart'`
for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
parameters in <...> which lint reads as HTML. Switched to backticks.
Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
Three issues, all related to the player surface:
1. Player UI didn't update on track change. audio_handler's
_onCurrentIndexChanged only kicked off the cover load — it never
pushed the new MediaItem onto the mediaItem stream. Title/artist/
cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
set on first play. Now the listener pushes queue[idx] when the
index changes.
2. Player kebab "Go to artist" 404'd while the same item from
MostPlayed worked. Same TrackActionsSheet for both, but the
player's _trackRefFromMediaItem was hardcoding artistId: ''
because audio_handler's _toMediaItem never stashed it in extras.
Stash artist_id alongside album_id; player_bar +
now_playing_screen read it back. Both kebabs now navigate.
3. "Start radio" didn't exist on Flutter even though the server has
/api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
fetches + plays the result via the existing playTracks path.
New menu item between "Add to playlist" and the divider above
"Go to album", calls startRadio with a snackbar error fallback.
System playlists now show in the home row (good!) but tapping them
landed on an empty playlist. Same root cause as the earlier album
bug: playlistsListProvider wrote the playlist *row* to drift but
never the tracks. playlistDetailProvider only triggered cold-fetch
when the row was missing, so it yielded with an empty track list.
Refactor playlistDetailProvider to mirror albumProvider's approach:
- fetchAttempted guard (one-shot per subscription).
- Detect "playlist row exists but trackRows empty" and trigger the
same fetchAndPopulate the cold-cache path uses. Drift watch
re-emits with populated tracks.
- 10s timeout on the API fetch + diagnostic prints so any failure
surfaces in logs.
While here, fix two adjacent issues:
- Wipe + re-insert this playlist's track positions on every fetch
so server-side deletions actually propagate. Without the wipe,
removed tracks would linger in drift forever.
- Write the artist + album rows referenced by the fetched tracks
into cachedArtists / cachedAlbums (deduped). Without this, the
joined artistName/albumTitle columns in the playlist track rows
surface empty.
Cover art for system playlists is a separate issue — server emits
coverUrl but it may be empty for system mixes; PlaylistCard falls
back to the queue_music icon. Will tackle in a follow-up.
Artists tab 404: client called /api/library/artists, but the server
mounts the artists list at /api/artists (handleListArtists). Albums
sit at /api/library/albums for historical reasons — the paths aren't
symmetric. Switch listArtists() to /api/artists with sort=alpha.
Albums tab grid: matched the responsive 3-up layout we built for
artist detail. LayoutBuilder computes cellW from available width;
AlbumCard sized to the cell with titleMaxLines: 2; mainAxisExtent
matches actual content height (cover + 2-line title + artist line +
fudge). No more wide-aspect cells with empty space below the card.
Also wired `extra: ref` on the artists/albums grids and the Liked
tab so detail-screen nav hydration kicks in here too — taps from
library screens get the same instant header that home tiles do.
Three changes addressing the cold-start spinner + stale-on-revisit pain.
A. SWR on remaining cacheFirst providers
- artistProvider, artistAlbumsProvider, artistTracksProvider all gain
alwaysRefresh: true. Cache hit still renders instantly; one
background refresh per subscription keeps the row from going stale
forever.
- albumProvider (inline async*) and playlistDetailProvider (inline
async*) now keep a `revalidated` flag and kick a one-shot
background fetch on the first complete cache hit. Same effect as
cacheFirst's alwaysRefresh, just inline.
- Three providers gained the connectivity timeout that
album/artist/playlist already had.
B. Navigation hydration
- AlbumDetailScreen accepts `AlbumRef? seed`; ArtistDetailScreen
accepts `ArtistRef? seed`. When the live provider is still loading,
the seed populates cover/title/artist immediately so the page
isn't blank.
- Routing wires `extra: AlbumRef|ArtistRef` from go_router into
the seed parameter.
- Call sites updated: home (Recently added, Rediscover albums +
artists, Last played), artist detail album grid. Where a ref isn't
available (track actions sheet), the screen falls back to the
spinner — no regression.
C. Cold-start home skeleton
- Replace the full-screen CircularProgressIndicator on /home with a
layout-preserving skeleton: 5 section titles + 6 grey card-shaped
placeholders per row. The page feels populated immediately;
sections fill in independently as data arrives via the per-section
providers.
- Drops the unused DelayedLoading import.
Net effect: re-visits to detail screens render instantly (cache hit
+ silent refresh); first visit from a tile shows the seed header
immediately while tracks load; cold-start home shows a layout
skeleton instead of a 30s blank spinner.
Web UI shows system playlists; Flutter shows placeholders. Wire shape,
adapters, drift schema, and filter constants all line up on inspection,
so adding instrumentation to pinpoint where the system rows fall out:
- Log what /api/playlists?kind=all actually returns (owned/public
counts, including how many of owned are system).
- Log what cacheFirst sees on each drift emit: total rows, filtered,
how many are system, how many landed in owned vs pub, plus the
user.id used for the owned-vs-pub split.
Also add the 3s connectivity timeout that albumProvider/artistProvider
got — keeps the alwaysRefresh path from blocking on a stalled
connectivity stream.
Three log lines from one home-screen visit will tell us:
- Does the server emit system rows? (wire log: system=N)
- Do they land in drift? (drift log: filtered system=N)
- Do they survive the user-id filter? (drift log: owned system=N)
Two test failures from the comparator rewrite:
1. "different unparseable strings → newer" was useful — branch-name
builds (e.g. PackageInfo reports 'main' while server reports
'dev') should still surface the banner so the operator sees that
something is misaligned. Restore that fallback: if both sides
parse to all-zero components (no numeric structure on either),
compare as strings.
2. "honors prerelease ordering" tested pub_semver behavior we no
longer use. Replaced with tests that cover what the new
comparator actually does: zero-padding for length mismatch,
leading-zero normalization, 4-part date+build comparisons, and
month-rollover correctness without leading zeros.
flutter pub get rejects "2026.05.11.0+1" — it requires strict
MAJOR.MINOR.PATCH+BUILD with no leading zeros. Use 2026.5.11+1
as the local default; CI's --build-name="${TAG#v}" still injects
the full 4-part tag for release builds, so production APKs report
the tag verbatim while local dev builds get a sensible recent value.
Update banner showing on identical versions:
- pubspec.yaml was stuck at the placeholder 0.1.0+1, so
PackageInfo.version returned "0.1.0" while the server reported the
actual release tag (e.g. "2026.05.10.1"). Comparison correctly said
"newer" → banner always showed.
- Bump pubspec to 2026.05.11.0+1 so the local default matches the
release cadence even before CI overrides it.
- Update flutter.yml release step to pass --build-name="${TAG#v}" so
every tagged APK reports the tag as its PackageInfo.version. Future
releases stop drifting from pubspec.
- Rewrite isVersionNewer to do component-wise int comparison with
zero-padding: pub_semver.Version.parse rejects 4-part date versions
like "2026.05.10.1", at which point the old code fell back to
string inequality and treated "2026.05.10" as newer than itself
vs "2026.05.10.0". Drop the pub_semver import (no longer used).
Lock-screen play/pause not responding:
- PlaybackState only listed MediaAction.seek in systemActions, which
on Android 13+ means tapping the lock-screen play/pause button
doesn't route back to the AudioHandler. Add play, pause,
skipToNext, skipToPrevious to the set.
- Add androidCompactActionIndices: [0, 1, 2] so the compact
notification view explicitly maps the three buttons.
Album art being smaller than the lock-screen frame is upstream of
this commit — the cover-cache writes whatever pixel dimensions the
server returns. If the server's /api/albums/<id>/cover returns small
thumbnails for these albums, the lock screen renders them at that
size. Worth a separate look at the server cover-emit path.
artistAlbumsProvider populates cachedAlbums (album rows only) without
ever fetching their tracks. When the user taps from the artist grid
into an album, albumProvider sees a drift hit on the album row, never
triggers cold-cache, and yields with an empty track list — visible as
the album header rendering above an empty body.
Refactor albumProvider's cold-cache logic into a helper closure that
either branch (no album row OR album row + no tracks) can call. Add a
fetchAttempted guard so a server response that legitimately returns
zero tracks doesn't loop forever.
Decision flow:
- albumRows empty: cold-fetch (existing path), yield empty on
failure/offline
- albumRows hit but trackRows empty AND not yet attempted: same
cold-fetch — drift watch re-emits with the populated tracks
- albumRows hit, trackRows hit (or attempted already): yield as-is
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
numbers cramp.
Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
track in album detail; layout change wasn't the cause. Real cause:
albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
never cachedArtists. The drift JOINs that build the AlbumRef + each
TrackRef returned null for the artist row, so artistName fell back
to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
off the album header + each track, inserts them into cachedArtists
alongside the album/tracks. Subsequent JOINs populate artistName,
the audio handler stamps it on MediaItem, and the mini player picks
it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
re-fetched (cache invalidation TBD). Future cold-cache hits will.
Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
detail grid since the page header already names the artist. cellH
trimmed by the 16dp artist line.
AlbumCard gains optional `width` (default 140 keeps horizontal lists
unchanged) and `titleMaxLines` (default 1) so callers can let the
title wrap to a second line. Cover sizes to width - 16 instead of a
hardcoded 124, so the card scales down cleanly when a narrower cell
width is passed in.
Artist detail grid: switch to 3 columns. LayoutBuilder computes the
cell width from the available width so AlbumCard sizes to the cell
exactly (no overflow). cellH = cover + gap + 2-line title + artist
line + small fudge — tight enough that there's no visible gap below
the card, tall enough to fit a wrapped title.
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.
Move the like + kebab buttons out of the title row and place them as
siblings to the title/artist column. Row's default crossAxisAlignment
centers them against the row's full 48dp height (set by the album
art), so visually they sit at the vertical center of the title+artist
block instead of being pinned to the title baseline.
Width stays 32dp each — horizontal footprint matches what we had.
Bumped icon size 18→20 since they have more vertical space to occupy.
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.
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.
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.
Image with width+height but no fit paints the source at its intrinsic
resolution inside the box. The cover-cache thumbnails are smaller
than 48dp, so the art rendered as a tiny inset in the slate box
instead of filling it. Cover stretches/crops uniformly to fill.
Why the seek bar appeared frozen: it was reading
PlaybackState.updatePosition, which audio_service only updates on
event transitions (play/pause/buffer/seek). Between events it sits
unchanged, so the bar only jumped at intervals.
Expose just_audio's positionStream (~200ms cadence) from the audio
handler, wrap as positionProvider, and read that in both the mini bar
and the full player. Now the bar advances continuously while playing.
While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
Title (22pt parchment) → artist (14pt ash) → album (12pt ash). Album
is conditional — drops out if MediaItem.album is empty rather than
leaving a blank line. Tightened the gap to the seek bar (24→20) to
absorb the new line on small screens.
You correctly diagnosed: the mini bar was still visible underneath
the full player, and that's also why the bottom controls overflowed
by 45px (the shell's mini bar was eating the bottom 88dp).
Move /now-playing OUT of the ShellRoute and up to a top-level GoRoute.
Now pushing /now-playing unmounts the shell entirely — no mini bar
underneath, no fight for the bottom strip. The slide-up transition
still feels right because the shell stays painted for the duration of
the animation (so during the transition both are briefly visible, as
you noted is fine).
Drop the hideMini conditional in _ShellWithPlayerBar — no longer
needed since the shell isn't even built when the full player is on
top. Recovering the 88dp also resolves the bottom-controls overflow
without needing to shrink the layout.
Mini player simplifies to track-info + prev/play/next. The
shuffle/repeat/queue cluster moves to the full player, where it has
the room to breathe.
Full player (NowPlayingScreen) now has:
- Real album art (FileImage when artUri is file://, ServerImage from
/api/albums/<id>/cover otherwise — same auth-aware loader as the
rest of the app, with errorBuilder so a failed fetch falls back to
a slate placeholder instead of a broken-image glyph).
- Title row with inline like + kebab.
- Full-width seek with start/end timestamps below.
- 36/72/36 prev/play/next.
- Secondary row: shuffle, repeat, queue.
Mini ↔ Full transition mechanics:
- Tap mini → push /now-playing with custom slide-up transition
(CustomTransitionPage, 280ms easeOutCubic).
- Vertical drag-up flick on mini (>200 px/s) → same push.
- System back / leading expand-more icon → Navigator.pop, which the
CustomTransitionPage replays as a slide-down (240ms easeInCubic).
- Drag-down on full player past 80px OR a >500 px/s downward flick
→ Navigator.pop. Buttons and the seek slider stay tappable since
Flutter's gesture arena gives priority to the more-specific child
recognizers.
- Mini player hides while /now-playing is the active route — the
full player IS the player UI in that state, no need to fight over
visual space.
User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.
Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:
- InkWell registers via the Material ripple system instead of the
raw gesture arena, sidestepping any subtle deferToChild edge cases
inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
visually — if the ripple shows but nothing navigates, the issue is
on the route side; if no ripple, the tap never landed.
Material color is transparent so the existing dark page background
shows through unchanged.
You correctly diagnosed the symptom: the AppBar gets taller when the
banner is present. Why: the shell is [Banner, Expanded(child),
PlayerBar] and the banner uses SafeArea(bottom:false) to clear the
status bar. But MediaQuery.padding is screen-relative — the child's
Scaffold/AppBar reads MediaQuery.padding.top and applies its own
status-bar inset on top of the banner, doubling the gap.
Watch shouldShowUpdateBannerProvider in the shell. When it returns
non-null (banner visible), wrap the child in MediaQuery.removePadding
removeTop: true so the AppBar mounts directly under the banner. When
the banner is hidden, child gets its normal top inset.
The 401s on /api/albums/<id>/cover were the root cause of "only the
first tile is navigable" — failed images leaving slate placeholders
that combined with the deferToChild hit-test (fixed in d703fc2) to
silently swallow taps.
ServerImage:
- sessionTokenProvider is a FutureProvider; .value is null after a
hot restart until secure-storage resolves. Old code fired
Image.network with headers:null at that moment, the network image
cache locked in the 401 response, and never retried. Switch to
AsyncValue.when so the widget waits for the token before mounting
Image.network with the auth header attached.
- Add errorBuilder so a single failed image renders the parent
fallback instead of leaking a stack trace + broken-icon glyph.
Player bar:
- media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping
it in NetworkImage attempts HTTP on a file:// URI and fails. Use
FileImage when the scheme is file, else NetworkImage.
Net effect: failed image loads no longer leak errors or block other
widgets from rendering / receiving taps.
Three issues from the screenshot:
1. Title invisible: LikeButton + TrackActionsButton were raw IconButtons
with the default 48dp tap target, eating ~96dp of the title row.
Wrap both in 32×32 SizedBox at the call site so the title actually
gets enough room to render (still ellipsizes if needed).
2. Layout per operator's revised spec: track info gets a 2:1 share of
the row vs the controls column. The controls column now stacks the
shuffle/repeat/queue sub-row above the prev/play/next sub-row, both
right-aligned.
3. Right-edge 2px overflow: previous play controls were 40+48+40=128dp
in a flex-1 slot of ~110dp. Resize to 32+40+32=104dp matching the
shuffle/repeat/queue row above (3×32=96dp), with the play button
slightly larger to keep visual hierarchy.
Drop the volume provider import since the slider is gone (handled by
phone hardware buttons).
Same root cause as the playlist card fix in f65650f — GestureDetector
defers to children by default, so taps over the cover image area
(ServerImage / SVG fallback) didn't reliably propagate. Mark the
detector opaque so the entire card surface is tappable, not just the
text below the cover.
Player bar: drop the volume slider — phone hardware controls volume.
Right cluster collapses to a single row of shuffle/repeat/queue.
Playlist card: GestureDetector defers to children by default, so taps
over the cover image area didn't always register. Add HitTestBehavior.opaque
so the whole card surface is tappable.
Update banner: was 3-4× the height of its text — IconButton's default
48dp tap target plus generous vertical padding dominated. Tighten
padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim
the action button to 28dp height with shrinkWrap tap target.
Home screen: ClampingScrollPhysics + 140dp bottom padding (already
applied in working tree) so scroll stops just above the mini-player.
The previous commit message said I added data-testid="player-bar-compact"
+ "player-bar-desktop" to PlayerBar.svelte but the edits actually
dropped silently — only the test file changes landed. Without the
testids, the test file's within(getByTestId(...)) calls all fail.
This adds the actual attributes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Replaces the kebab-with-popover PlayerOverflowMenu approach with all
player controls visible inline. Operator's stated intent was "two-row
layout with all controls visible," not "kebab hides shuffle/repeat/
queue/volume" — restructured to match.
### Compact view (below md)
┌─────────┬───────────┬───────────────────┐
│ art │ ♥ ⋮ │ 🔀🔁 ☰ │ ← short sub-row
│ title │ │ │
│ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play row at full size
├─────────┴───────────┴───────────────────┤
│ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
└─────────────────────────────────────────┘
- Column 1: cover + title/artist
- Column 2: like+kebab on top sub-row, play controls (40/48/40) below
- Column 3: shuffle/repeat/queue on top, volume slider below
- Bottom: full-width seek slider with time labels
### Desktop view (md+)
Existing 3-column layout preserved; only change is the redundant
PlayerOverflowMenu kebab (which had been mounting at all widths)
is no longer there. Volume + shuffle + repeat + queue stay inline
in the right cluster as before.
### Cleanup
- Deleted PlayerOverflowMenu.svelte + its test (no callers remain)
- The TrackMenu kebab (per-track actions) stays — it's a varying
list of actions that doesn't fit inline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Came up debugging the in-app update flow — wasn't obvious which image
the container was actually running without exec'ing in. New flow:
### Server
- internal/server/version.go: new `var ServerVersion = "dev"`,
overridden via -ldflags at build time.
- /healthz response gains a "version" key alongside the existing
"status" + "min_client_version". Backward-compat: existing clients
ignore unknown JSON fields. Endpoint stays unauthenticated.
### Build
- Dockerfile: new `ARG MINSTREL_VERSION=dev`, threaded into the
go build -ldflags so the binary's ServerVersion is stamped at
link time. Default "dev" preserves local `docker build` ergonomics.
- .forgejo/workflows/release.yml: tags step also emits a `version`
output (the git tag for tag pushes, "main" for branch pushes);
build step passes it as `--build-arg MINSTREL_VERSION=...`.
### Web
- web/src/lib/components/ServerVersion.svelte: small understated
text ("Server v2026.05.10.2") that fetches /healthz on mount.
Renders nothing on parse failure or pre-version images.
- Mounted at the bottom of Settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server's clientVersionResponse marshals to {version, apk_url,
size_bytes} but the component was reading body.apkUrl / body.sizeBytes
(camelCase). The !body.apkUrl guard was always true → early return →
download button never rendered even when /api/client/version returned
200 with valid data.
Verified directly: /api/client/version returns
{"version":"v2026.05.10.1","apk_url":"/api/client/apk","size_bytes":65301242}
on the deployed v2026.05.10.1 image; the UI just wasn't reading those
keys.
Map wire (snake_case) → component (camelCase) explicitly via a
WireInfo type so the boundary is auditable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When /api/client/version returns 404 (no APK in /app/client/, e.g.
v2026.05.10.0 which had a failed CI APK-attach step), the previous
shape rendered the section heading + intro paragraph with an empty
hole where the button should be. Confusing — looks like a broken
button.
Moved the section wrapper, heading, and intro paragraph INSIDE
MobileAppDownload so the whole block collapses together when info
is null. Settings page just mounts <MobileAppDownload />; nothing
visible if no APK is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both prior secrets did related work (one for registry push, one for
release asset I/O) and required separate Forgejo PATs to manage.
Replacing with a single CI_TOKEN keyed off a single PAT with the
union of scopes (write:repository + write:package).
- flutter.yml: APK attach step → CI_TOKEN
- release.yml: docker login → CI_TOKEN; APK fetch step → CI_TOKEN
No behavior change — same calls, single secret name. Operator needs
to set CI_TOKEN in repo settings (Actions → Secrets) with a PAT that
has write:repository + write:package; the prior REGISTRY_TOKEN /
RELEASE_TOKEN secrets can be removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>