Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.
- EventsApi.playOffline: single timestamp-preserving call → the new
/api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
- _beginTrack captures start context + fires live play_started;
the server id is adopted only if it lands while still on-track.
- position progress gated on the tracked track id so a track
change can't clobber the finishing track's last values.
- _closeCurrent: if a server id registered, attempt the live
ended/skipped and fall back to the offline queue on failure; if
no id (offline start) enqueue the completed play directly. The
server applies the canonical skip rule, so the offline payload
only carries duration.
- app paused/detached closes durably via the queue (survives a
process kill; a teardown POST would not).
Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>