// 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 '../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; String? _openPlayEventId; String? _openTrackId; String? _prevTrackId; int _lastPositionMs = 0; int _openDurationMs = 0; 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) { _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 mi = handler.mediaItem.value; if (mi != null && mi.id == _openTrackId && mi.duration != null) { _openDurationMs = mi.duration!.inMilliseconds; } })); _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 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((_) {}); } } // Entered playing for a new track → open a row. if (tid != null && playing && _openTrackId != tid) { _startNew(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((_) {}); } _prevTrackId = tid; } Future _startNew(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: handler.queueSource, ); // 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) { _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. } } @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. if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { final id = _openPlayEventId; if (id != null) { _api ?.playSkipped(playEventId: id, positionMs: _lastPositionMs) .catchError((_) {}); } } } 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; });