feat(flutter): #426B client — offline play capture via mutation queue

Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.

- EventsApi.playOffline: single timestamp-preserving call → the new
  /api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
  - _beginTrack captures start context + fires live play_started;
    the server id is adopted only if it lands while still on-track.
  - position progress gated on the tracked track id so a track
    change can't clobber the finishing track's last values.
  - _closeCurrent: if a server id registered, attempt the live
    ended/skipped and fall back to the offline queue on failure; if
    no id (offline start) enqueue the completed play directly. The
    server applies the canonical skip rule, so the offline payload
    only carries duration.
  - app paused/detached closes durably via the queue (survives a
    process kill; a teardown POST would not).

Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 11:19:11 -04:00
parent 47aa178850
commit a571282031
3 changed files with 172 additions and 52 deletions
@@ -25,6 +25,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/events.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
import '../cache/mutation_queue.dart'
show MutationKinds, mutationQueueProvider;
import '../library/library_providers.dart' show dioProvider;
import 'audio_handler.dart' show MinstrelAudioHandler;
import 'player_provider.dart' show audioHandlerProvider;
@@ -45,11 +47,21 @@ class PlayEventsReporter with WidgetsBindingObserver {
String? _clientId;
EventsApi? _api;
// Server play_event_id when the live play_started succeeded; null
// if start failed / offline — then the close is captured into the
// offline mutation queue instead of a live ended/skipped call.
String? _openPlayEventId;
String? _openTrackId;
// The play currently being tracked, captured independently of
// connectivity so an offline play is still a complete record.
String? _curTrackId;
DateTime? _curStartedAt;
String? _curSource;
int _curLastPositionMs = 0;
int _curDurationMs = 0;
bool _curReachedEnd = false;
String? _prevTrackId;
int _lastPositionMs = 0;
int _openDurationMs = 0;
Future<void> start() async {
final MinstrelAudioHandler handler;
@@ -69,13 +81,21 @@ class PlayEventsReporter with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
_subs.add(handler.positionStream.listen((p) {
_lastPositionMs = p.inMilliseconds;
// Keep the open track's duration current while it's the one
// playing, so on a track change we still know how long the
// (now-prior) track was.
final ms = p.inMilliseconds;
// Advance the tracked play's progress ONLY while its track is
// the current one. A track change resets position to 0; gating
// on _curTrackId keeps the finishing track's last-known values
// intact for the close branch.
final mi = handler.mediaItem.value;
if (mi != null && mi.id == _openTrackId && mi.duration != null) {
_openDurationMs = mi.duration!.inMilliseconds;
if (mi != null && mi.id == _curTrackId) {
_curLastPositionMs = ms;
final d = mi.duration;
if (d != null && d.inMilliseconds > 0) {
_curDurationMs = d.inMilliseconds;
if (ms >= _curDurationMs - _completionToleranceMs) {
_curReachedEnd = true;
}
}
}
}));
_subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
@@ -90,44 +110,42 @@ class PlayEventsReporter with WidgetsBindingObserver {
final playing = st.playing;
final completed = st.processingState == AudioProcessingState.completed;
// Track changed with an open row → close the prior row. Natural
// completion (reached ~duration) ends it; otherwise it's a skip.
if (tid != _prevTrackId && _openPlayEventId != null) {
final id = _openPlayEventId!;
final pos = _lastPositionMs;
_openPlayEventId = null;
_openTrackId = null;
final finished = _openDurationMs > 0 &&
pos >= _openDurationMs - _completionToleranceMs;
if (finished) {
_api?.playEnded(playEventId: id, durationPlayedMs: pos)
.catchError((_) {});
} else {
_api?.playSkipped(playEventId: id, positionMs: pos)
.catchError((_) {});
}
// Track changed → close the prior tracked play.
if (tid != _prevTrackId && _curTrackId != null) {
_closeCurrent(viaOffline: false);
}
// Entered playing for a new track → open a row.
if (tid != null && playing && _openTrackId != tid) {
_startNew(handler, tid);
// Entered playing for a new track → begin tracking it.
if (tid != null && playing && _curTrackId != tid) {
_beginTrack(handler, tid);
}
// Whole-queue natural end (just_audio only emits `completed` at
// the end of the sequence, not between items) → end the open row.
if (completed && _openPlayEventId != null) {
final id = _openPlayEventId!;
final dur = _lastPositionMs;
_openPlayEventId = null;
_openTrackId = null;
_api?.playEnded(playEventId: id, durationPlayedMs: dur)
.catchError((_) {});
// the end of the sequence, not between items) → close it.
if (completed && _curTrackId != null) {
_curReachedEnd = true;
_closeCurrent(viaOffline: false);
}
_prevTrackId = tid;
}
Future<void> _startNew(MinstrelAudioHandler handler, String trackId) async {
void _beginTrack(MinstrelAudioHandler handler, String trackId) {
_curTrackId = trackId;
_curStartedAt = DateTime.now().toUtc();
_curSource = handler.queueSource;
_curLastPositionMs = 0;
_curReachedEnd = false;
final d = handler.mediaItem.value?.duration;
_curDurationMs = d?.inMilliseconds ?? 0;
_openPlayEventId = null;
// Fire the live play_started; adopt the server id only if we're
// still on this track when the response lands. Failure is fine —
// the close path captures the whole play into the offline queue.
_startLive(handler, trackId);
}
Future<void> _startLive(MinstrelAudioHandler handler, String trackId) async {
final api = _api;
final cid = _clientId;
if (api == null || cid == null) return;
@@ -135,32 +153,93 @@ class PlayEventsReporter with WidgetsBindingObserver {
final id = await api.playStarted(
trackId: trackId,
clientId: cid,
source: handler.queueSource,
source: _curSource,
);
// The user may have moved on by the time the response lands —
// only adopt the id if we're still on the same track.
if (id != null && handler.mediaItem.value?.id == trackId) {
if (id != null && _curTrackId == trackId) {
_openPlayEventId = id;
_openTrackId = trackId;
final d = handler.mediaItem.value?.duration;
_openDurationMs = d?.inMilliseconds ?? 0;
}
} catch (_) {
// Best-effort; a missed event is acceptable per spec v1.
// Offline / flaky — _openPlayEventId stays null; the close path
// enqueues the completed play for replay.
}
}
/// Closes the currently-tracked play. `finished` is derived from
/// whether it reached ~its duration. If the live start registered a
/// server id we attempt the live ended/skipped close and fall back
/// to the offline queue on failure; with no server id (offline
/// start) — or viaOffline (app teardown, must be durable) — the
/// completed play is enqueued directly. The server's RecordOffline
/// Play applies the canonical skip rule, so the offline payload
/// only needs duration, not our finished/skipped guess.
void _closeCurrent({required bool viaOffline}) {
final trackId = _curTrackId;
final startedAt = _curStartedAt;
if (trackId == null || startedAt == null) {
_resetCurrent();
return;
}
final reached = _curReachedEnd;
final lastPos = _curLastPositionMs;
final durationMs = (reached && _curDurationMs > 0)
? _curDurationMs
: lastPos;
final source = _curSource;
final id = _openPlayEventId;
if (!viaOffline && id != null) {
// Live close; on failure, fall back to the durable offline path
// so a transient blip at close time doesn't lose the play.
final fut = reached
? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs)
: _api?.playSkipped(playEventId: id, positionMs: lastPos);
fut?.catchError((_) {
_enqueueOffline(trackId, startedAt, source, durationMs);
});
} else {
_enqueueOffline(trackId, startedAt, source, durationMs);
}
_resetCurrent();
}
void _resetCurrent() {
_curTrackId = null;
_curStartedAt = null;
_curSource = null;
_curLastPositionMs = 0;
_curDurationMs = 0;
_curReachedEnd = false;
_openPlayEventId = null;
}
void _enqueueOffline(
String trackId,
DateTime startedAt,
String? source,
int durationPlayedMs,
) {
final cid = _clientId;
if (cid == null) return;
// ignore: unawaited_futures
_ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, {
'trackId': trackId,
'clientId': cid,
'at': startedAt.toIso8601String(),
'durationPlayedMs': durationPlayedMs,
if (source != null && source.isNotEmpty) 'source': source,
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// App backgrounded / killed with a live row: best-effort skip
// close so the row doesn't dangle. Mirrors web's pagehide beacon.
// App backgrounded / killed mid-play: close durably via the
// offline queue (a fire-and-forget POST during teardown is
// unreliable; the queue survives a process kill and drains on
// next launch). Mirrors the intent of web's pagehide beacon.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
final id = _openPlayEventId;
if (id != null) {
_api
?.playSkipped(playEventId: id, positionMs: _lastPositionMs)
.catchError((_) {});
if (_curTrackId != null) {
_closeCurrent(viaOffline: true);
}
}
}