diff --git a/flutter_client/lib/api/endpoints/events.dart b/flutter_client/lib/api/endpoints/events.dart index a20d9376..c5ab0fc0 100644 --- a/flutter_client/lib/api/endpoints/events.dart +++ b/flutter_client/lib/api/endpoints/events.dart @@ -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 playOffline({ + required String trackId, + required String clientId, + required String atIso, + required int durationPlayedMs, + String? source, + }) async { + await _dio.post( + '/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, + }, + ); + } } diff --git a/flutter_client/lib/cache/mutation_queue.dart b/flutter_client/lib/cache/mutation_queue.dart index 7e7bb354..5077f892 100644 --- a/flutter_client/lib/cache/mutation_queue.dart +++ b/flutter_client/lib/cache/mutation_queue.dart @@ -30,6 +30,7 @@ import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/discover.dart'; +import '../api/endpoints/events.dart'; import '../api/endpoints/likes.dart'; import '../library/library_providers.dart' show dioProvider; import '../likes/likes_provider.dart' show likesApiProvider; @@ -52,6 +53,7 @@ class MutationKinds { static const playlistAppend = 'playlist.append'; static const requestCreate = 'request.create'; static const requestCancel = 'request.cancel'; + static const playOffline = 'play.offline'; } class MutationQueue { @@ -237,6 +239,8 @@ typedef _Handler = Future Function(Ref, Map); /// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]} /// * request.create: {full createRequest args; see DiscoverApi} /// * request.cancel: {'id': uuid} +/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601, +/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'} final Map _handlers = { MutationKinds.likeAdd: (ref, p) async { final api = await ref.read(likesApiProvider.future); @@ -281,6 +285,17 @@ final Map _handlers = { final api = await ref.read(requestsApiProvider.future); await api.cancel(p['id'] as String); }, + MutationKinds.playOffline: (ref, p) async { + final dio = await ref.read(dioProvider.future); + final api = EventsApi(dio); + await api.playOffline( + trackId: p['trackId'] as String, + clientId: p['clientId'] as String, + atIso: p['at'] as String, + durationPlayedMs: (p['durationPlayedMs'] as num).toInt(), + source: p['source'] as String?, + ); + }, }; LikeKind _likeKindFromString(String s) => switch (s) { diff --git a/flutter_client/lib/player/play_events_reporter.dart b/flutter_client/lib/player/play_events_reporter.dart index 3c3fb9dd..a06d2630 100644 --- a/flutter_client/lib/player/play_events_reporter.dart +++ b/flutter_client/lib/player/play_events_reporter.dart @@ -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 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 _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 _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); } } }