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
@@ -62,4 +62,30 @@ class EventsApi {
},
);
}
/// Replays a complete play that happened offline / on a flaky
/// connection (#426 part B). One call: the server records start+end
/// from `atIso` (the original play-start time) + durationPlayedMs,
/// applies the canonical skip rule, and advances #415 rotation when
/// source is a system playlist. Driven by the offline mutation
/// queue, never the live path.
Future<void> playOffline({
required String trackId,
required String clientId,
required String atIso,
required int durationPlayedMs,
String? source,
}) async {
await _dio.post<void>(
'/api/events',
data: {
'type': 'play_offline',
'track_id': trackId,
'client_id': clientId,
'at': atIso,
'duration_played_ms': durationPlayedMs,
if (source != null && source.isNotEmpty) 'source': source,
},
);
}
}