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:
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -30,6 +30,7 @@ import 'package:flutter/foundation.dart' show debugPrint;
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/discover.dart';
|
import '../api/endpoints/discover.dart';
|
||||||
|
import '../api/endpoints/events.dart';
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../likes/likes_provider.dart' show likesApiProvider;
|
import '../likes/likes_provider.dart' show likesApiProvider;
|
||||||
@@ -52,6 +53,7 @@ class MutationKinds {
|
|||||||
static const playlistAppend = 'playlist.append';
|
static const playlistAppend = 'playlist.append';
|
||||||
static const requestCreate = 'request.create';
|
static const requestCreate = 'request.create';
|
||||||
static const requestCancel = 'request.cancel';
|
static const requestCancel = 'request.cancel';
|
||||||
|
static const playOffline = 'play.offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
class MutationQueue {
|
class MutationQueue {
|
||||||
@@ -237,6 +239,8 @@ typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
|
|||||||
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
||||||
/// * request.create: {full createRequest args; see DiscoverApi}
|
/// * request.create: {full createRequest args; see DiscoverApi}
|
||||||
/// * request.cancel: {'id': uuid}
|
/// * request.cancel: {'id': uuid}
|
||||||
|
/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601,
|
||||||
|
/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'}
|
||||||
final Map<String, _Handler> _handlers = {
|
final Map<String, _Handler> _handlers = {
|
||||||
MutationKinds.likeAdd: (ref, p) async {
|
MutationKinds.likeAdd: (ref, p) async {
|
||||||
final api = await ref.read(likesApiProvider.future);
|
final api = await ref.read(likesApiProvider.future);
|
||||||
@@ -281,6 +285,17 @@ final Map<String, _Handler> _handlers = {
|
|||||||
final api = await ref.read(requestsApiProvider.future);
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
await api.cancel(p['id'] as String);
|
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) {
|
LikeKind _likeKindFromString(String s) => switch (s) {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../api/endpoints/events.dart';
|
import '../api/endpoints/events.dart';
|
||||||
import '../auth/auth_provider.dart' show secureStorageProvider;
|
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||||
|
import '../cache/mutation_queue.dart'
|
||||||
|
show MutationKinds, mutationQueueProvider;
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import 'audio_handler.dart' show MinstrelAudioHandler;
|
import 'audio_handler.dart' show MinstrelAudioHandler;
|
||||||
import 'player_provider.dart' show audioHandlerProvider;
|
import 'player_provider.dart' show audioHandlerProvider;
|
||||||
@@ -45,11 +47,21 @@ class PlayEventsReporter with WidgetsBindingObserver {
|
|||||||
String? _clientId;
|
String? _clientId;
|
||||||
EventsApi? _api;
|
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? _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;
|
String? _prevTrackId;
|
||||||
int _lastPositionMs = 0;
|
|
||||||
int _openDurationMs = 0;
|
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
final MinstrelAudioHandler handler;
|
final MinstrelAudioHandler handler;
|
||||||
@@ -69,13 +81,21 @@ class PlayEventsReporter with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
_subs.add(handler.positionStream.listen((p) {
|
_subs.add(handler.positionStream.listen((p) {
|
||||||
_lastPositionMs = p.inMilliseconds;
|
final ms = p.inMilliseconds;
|
||||||
// Keep the open track's duration current while it's the one
|
// Advance the tracked play's progress ONLY while its track is
|
||||||
// playing, so on a track change we still know how long the
|
// the current one. A track change resets position to 0; gating
|
||||||
// (now-prior) track was.
|
// on _curTrackId keeps the finishing track's last-known values
|
||||||
|
// intact for the close branch.
|
||||||
final mi = handler.mediaItem.value;
|
final mi = handler.mediaItem.value;
|
||||||
if (mi != null && mi.id == _openTrackId && mi.duration != null) {
|
if (mi != null && mi.id == _curTrackId) {
|
||||||
_openDurationMs = mi.duration!.inMilliseconds;
|
_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)));
|
_subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
|
||||||
@@ -90,44 +110,42 @@ class PlayEventsReporter with WidgetsBindingObserver {
|
|||||||
final playing = st.playing;
|
final playing = st.playing;
|
||||||
final completed = st.processingState == AudioProcessingState.completed;
|
final completed = st.processingState == AudioProcessingState.completed;
|
||||||
|
|
||||||
// Track changed with an open row → close the prior row. Natural
|
// Track changed → close the prior tracked play.
|
||||||
// completion (reached ~duration) ends it; otherwise it's a skip.
|
if (tid != _prevTrackId && _curTrackId != null) {
|
||||||
if (tid != _prevTrackId && _openPlayEventId != null) {
|
_closeCurrent(viaOffline: false);
|
||||||
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((_) {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entered playing for a new track → open a row.
|
// Entered playing for a new track → begin tracking it.
|
||||||
if (tid != null && playing && _openTrackId != tid) {
|
if (tid != null && playing && _curTrackId != tid) {
|
||||||
_startNew(handler, tid);
|
_beginTrack(handler, tid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whole-queue natural end (just_audio only emits `completed` at
|
// Whole-queue natural end (just_audio only emits `completed` at
|
||||||
// the end of the sequence, not between items) → end the open row.
|
// the end of the sequence, not between items) → close it.
|
||||||
if (completed && _openPlayEventId != null) {
|
if (completed && _curTrackId != null) {
|
||||||
final id = _openPlayEventId!;
|
_curReachedEnd = true;
|
||||||
final dur = _lastPositionMs;
|
_closeCurrent(viaOffline: false);
|
||||||
_openPlayEventId = null;
|
|
||||||
_openTrackId = null;
|
|
||||||
_api?.playEnded(playEventId: id, durationPlayedMs: dur)
|
|
||||||
.catchError((_) {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_prevTrackId = tid;
|
_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 api = _api;
|
||||||
final cid = _clientId;
|
final cid = _clientId;
|
||||||
if (api == null || cid == null) return;
|
if (api == null || cid == null) return;
|
||||||
@@ -135,32 +153,93 @@ class PlayEventsReporter with WidgetsBindingObserver {
|
|||||||
final id = await api.playStarted(
|
final id = await api.playStarted(
|
||||||
trackId: trackId,
|
trackId: trackId,
|
||||||
clientId: cid,
|
clientId: cid,
|
||||||
source: handler.queueSource,
|
source: _curSource,
|
||||||
);
|
);
|
||||||
// The user may have moved on by the time the response lands —
|
if (id != null && _curTrackId == trackId) {
|
||||||
// only adopt the id if we're still on the same track.
|
|
||||||
if (id != null && handler.mediaItem.value?.id == trackId) {
|
|
||||||
_openPlayEventId = id;
|
_openPlayEventId = id;
|
||||||
_openTrackId = trackId;
|
|
||||||
final d = handler.mediaItem.value?.duration;
|
|
||||||
_openDurationMs = d?.inMilliseconds ?? 0;
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} 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
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
// App backgrounded / killed with a live row: best-effort skip
|
// App backgrounded / killed mid-play: close durably via the
|
||||||
// close so the row doesn't dangle. Mirrors web's pagehide beacon.
|
// 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 ||
|
if (state == AppLifecycleState.paused ||
|
||||||
state == AppLifecycleState.detached) {
|
state == AppLifecycleState.detached) {
|
||||||
final id = _openPlayEventId;
|
if (_curTrackId != null) {
|
||||||
if (id != null) {
|
_closeCurrent(viaOffline: true);
|
||||||
_api
|
|
||||||
?.playSkipped(playEventId: id, positionMs: _lastPositionMs)
|
|
||||||
.catchError((_) {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user