// 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 = >[]; 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 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 _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 _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.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((ref) { final r = PlayEventsReporter(ref); ref.onDispose(r.dispose); // ignore: unawaited_futures r.start(); return r; });