a571282031
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>
278 lines
9.6 KiB
Dart
278 lines
9.6 KiB
Dart
// Flutter play-event lifecycle reporter (#415 stage 3).
|
|
//
|
|
// The Flutter client previously reported NO plays — listening on
|
|
// mobile never reached the server's play_events, so history,
|
|
// recommendation scoring, ListenBrainz scrobbles, and (since #415)
|
|
// system-playlist rotation all missed mobile activity entirely. This
|
|
// closes that gap and is the path that carries the #415 `source` tag.
|
|
//
|
|
// State machine over (current track id, playing). Mirrors the web
|
|
// events dispatcher, with one deliberate divergence: when a track
|
|
// changes inside a queue we classify ended-vs-skipped by whether the
|
|
// prior track reached (near) its duration, instead of the web
|
|
// dispatcher's blanket "track change = skip". Blanket-skip would mark
|
|
// every naturally-finished in-queue track as a skip and dilute the
|
|
// recommendation skip-ratio — the exact failure mode that motivated
|
|
// doing this properly. (The web dispatcher likely has the same
|
|
// false-skip issue; flagged separately, not fixed here.)
|
|
|
|
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
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;
|
|
|
|
const _clientIdKey = 'play_events_client_id';
|
|
|
|
/// Tolerance for "the track basically finished": within 3s of the
|
|
/// known duration counts as a natural completion, not a skip.
|
|
const _completionToleranceMs = 3000;
|
|
|
|
class PlayEventsReporter with WidgetsBindingObserver {
|
|
PlayEventsReporter(this._ref);
|
|
final Ref _ref;
|
|
|
|
final _subs = <StreamSubscription<dynamic>>[];
|
|
bool _disposed = false;
|
|
|
|
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;
|
|
|
|
// 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;
|
|
|
|
Future<void> start() async {
|
|
final MinstrelAudioHandler handler;
|
|
try {
|
|
// audioHandlerProvider throws until main() overrides it (real
|
|
// app always does). In tests / no-audio environments there's
|
|
// nothing to report — fail safe and stay inert rather than
|
|
// surfacing an unhandled async error.
|
|
handler = _ref.read(audioHandlerProvider);
|
|
_clientId = await _resolveClientId();
|
|
final dio = await _ref.read(dioProvider.future);
|
|
_api = EventsApi(dio);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
if (_disposed) return;
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
_subs.add(handler.positionStream.listen((p) {
|
|
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 == _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)));
|
|
_subs.add(handler.playbackState.listen((_) => _evaluate(handler)));
|
|
}
|
|
|
|
void _evaluate(MinstrelAudioHandler handler) {
|
|
if (_disposed) return;
|
|
final mi = handler.mediaItem.value;
|
|
final st = handler.playbackState.value;
|
|
final tid = mi?.id;
|
|
final playing = st.playing;
|
|
final completed = st.processingState == AudioProcessingState.completed;
|
|
|
|
// Track changed → close the prior tracked play.
|
|
if (tid != _prevTrackId && _curTrackId != null) {
|
|
_closeCurrent(viaOffline: false);
|
|
}
|
|
|
|
// 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) → close it.
|
|
if (completed && _curTrackId != null) {
|
|
_curReachedEnd = true;
|
|
_closeCurrent(viaOffline: false);
|
|
}
|
|
|
|
_prevTrackId = tid;
|
|
}
|
|
|
|
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;
|
|
try {
|
|
final id = await api.playStarted(
|
|
trackId: trackId,
|
|
clientId: cid,
|
|
source: _curSource,
|
|
);
|
|
if (id != null && _curTrackId == trackId) {
|
|
_openPlayEventId = id;
|
|
}
|
|
} catch (_) {
|
|
// 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 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) {
|
|
if (_curTrackId != null) {
|
|
_closeCurrent(viaOffline: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String> _resolveClientId() async {
|
|
final storage = _ref.read(secureStorageProvider);
|
|
final existing = await storage.read(key: _clientIdKey);
|
|
if (existing != null && existing.isNotEmpty) return existing;
|
|
final rnd = Random.secure();
|
|
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
|
final id =
|
|
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
await storage.write(key: _clientIdKey, value: id);
|
|
return id;
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
for (final s in _subs) {
|
|
s.cancel();
|
|
}
|
|
_subs.clear();
|
|
}
|
|
}
|
|
|
|
/// Read once at app start (app.dart postFrame) to activate reporting.
|
|
/// Disposed via ref.onDispose when the scope tears down.
|
|
final playEventsReporterProvider = Provider<PlayEventsReporter>((ref) {
|
|
final r = PlayEventsReporter(ref);
|
|
ref.onDispose(r.dispose);
|
|
// ignore: unawaited_futures
|
|
r.start();
|
|
return r;
|
|
});
|