feat(flutter): #415 stage 3 — play-events reporter + rotation wiring
The Flutter client previously reported NO plays — mobile listening never reached play_events, so history, recommendation scoring, ListenBrainz scrobbles, and #415 rotation all missed mobile entirely. Operator chose to close that gap properly as part of Stage 3. New: - EventsApi (api/endpoints/events.dart): play_started/ended/skipped. - PlayEventsReporter (player/play_events_reporter.dart): state machine over (track id, playing) mirroring the web dispatcher. Persists an opaque client_id in secure storage. Deliberate divergence from web: a track change inside a queue is classified ended-vs-skipped by whether the prior track reached ~its duration (3s tolerance), instead of web's blanket "track change = skip" which would mark every naturally-finished in-queue track a skip and dilute recommendation skip-ratios — the exact failure mode that motivated doing this properly. Fail-safe: no-ops when there's no audio handler (tests / no-audio env). App-lifecycle paused/ detached closes an open row as a best-effort skip (web pagehide parity). Wired in app.dart postFrame. - PlaylistsApi.systemShuffle(variant): GET the rotation-aware order. Wiring: - audio_handler: _queueSource carried through setQueueFromTracks (source param); preserved across internal skipToQueueItem rebuild. - player_provider.playTracks: source param → setQueueFromTracks. - PlaylistCard: system playlists fetch systemShuffle and play as-is tagged with source (no client shuffle — server already ordered). - playlist_detail_screen: header Play + per-track tap tag source for system playlists so rotation advances from any entry point. Known/flagged separately: the web dispatcher likely has the same false-skip-on-advance issue; not fixed here to keep #415 scoped and clients' wire behavior comparable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// 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 = <StreamSubscription<dynamic>>[];
|
||||
bool _disposed = false;
|
||||
|
||||
String? _clientId;
|
||||
EventsApi? _api;
|
||||
|
||||
String? _openPlayEventId;
|
||||
String? _openTrackId;
|
||||
String? _prevTrackId;
|
||||
int _lastPositionMs = 0;
|
||||
int _openDurationMs = 0;
|
||||
|
||||
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) {
|
||||
_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<void> _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<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;
|
||||
});
|
||||
Reference in New Issue
Block a user