The Flutter client previously reported NO plays — mobile listening
never reached play_events, so history, recommendation scoring,
ListenBrainz scrobbles, and #415 rotation all missed mobile entirely.
Operator chose to close that gap properly as part of Stage 3.
New:
- EventsApi (api/endpoints/events.dart): play_started/ended/skipped.
- PlayEventsReporter (player/play_events_reporter.dart): state
machine over (track id, playing) mirroring the web dispatcher.
Persists an opaque client_id in secure storage. Deliberate
divergence from web: a track change inside a queue is classified
ended-vs-skipped by whether the prior track reached ~its duration
(3s tolerance), instead of web's blanket "track change = skip"
which would mark every naturally-finished in-queue track a skip
and dilute recommendation skip-ratios — the exact failure mode
that motivated doing this properly. Fail-safe: no-ops when there's
no audio handler (tests / no-audio env). App-lifecycle paused/
detached closes an open row as a best-effort skip (web pagehide
parity). Wired in app.dart postFrame.
- PlaylistsApi.systemShuffle(variant): GET the rotation-aware order.
Wiring:
- audio_handler: _queueSource carried through setQueueFromTracks
(source param); preserved across internal skipToQueueItem rebuild.
- player_provider.playTracks: source param → setQueueFromTracks.
- PlaylistCard: system playlists fetch systemShuffle and play as-is
tagged with source (no client shuffle — server already ordered).
- playlist_detail_screen: header Play + per-track tap tag source for
system playlists so rotation advances from any entry point.
Known/flagged separately: the web dispatcher likely has the same
false-skip-on-advance issue; not fixed here to keep #415 scoped and
clients' wire behavior comparable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Flutter half of Fable #416. Web got a generalized
system-playlist refresh kebab in d12afda; this brings Flutter to
parity instead of leaving the affordance web-only.
- PlaylistsApi.refreshSystem(variant): POST
/api/playlists/system/{for-you|discover}/refresh, maps the
underscore model variant to the hyphenated route segment,
returns the rotated playlist id.
- PlaylistCard: top-right PopupMenuButton on system playlists
with a context-labelled "Refresh For You" / "Refresh Discover"
item. Calls refreshSystem, invalidates playlistsListProvider
(which reconciles the rotated UUID + new tracks), snackbars
the result. ScaffoldMessenger captured pre-await.
- Tests: kebab present for system, absent for user playlists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Fable #412 (For You force-refresh on play) and #413
(shuffle-on-play default for system playlists).
Web (PlaylistCard.svelte + player/store.svelte.ts):
- Drop the refreshForYou() call from the play handler. The daily
03:00 user-local snapshot is what plays now. Stops burning server
compute on every press and stops swapping the playlist out from
under the user.
- Generalize the kebab affordance to render for any system playlist
(was Discover-only). Adds "Refresh For You" as an explicit
replacement so users can still force a regen when they want one.
- Extend playQueue(tracks, startIndex, { shuffle? }) to Fisher-Yates
the queue when shuffle:true. PlaylistCard passes shuffle:true for
any non-null system_variant.
Flutter (player_provider.dart + playlists/widgets/playlist_card.dart):
- playTracks now accepts shuffle:bool. When true, picks a random
starting index and enables AudioServiceShuffleMode.all after
setQueueFromTracks. PlaylistCard passes shuffle:playlist.isSystem.
User playlists keep linear order. Detail-screen play buttons are
unchanged for now (follow-up if user requests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.
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.
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.
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.
Three info-level lints from CI:
- playlist_card.dart doc comment: "/playlists/<id>" → "/playlists/{id}"
in backticks, since `<id>` was flagged as unintended HTML.
- compact_track_card_test.dart: home: Scaffold(...) is now const, which
propagates const to CompactTrackCard and makes the inner [_track]
implicitly const.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.
PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"
Used in the next commit's home Playlists row; otherwise unused this
slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>