3054e8702b
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>
66 lines
1.9 KiB
Dart
66 lines
1.9 KiB
Dart
import 'package:dio/dio.dart';
|
|
|
|
/// Thin client for POST /api/events — the play-event lifecycle the
|
|
/// server uses for history, recommendation scoring, ListenBrainz
|
|
/// scrobbles, and (since #415) system-playlist rotation.
|
|
///
|
|
/// Mirrors the web events dispatcher's three calls. Best-effort by
|
|
/// contract: callers swallow errors — a missed event is acceptable
|
|
/// per the server spec's v1 stance, and the server's
|
|
/// auto-close-prior-open keeps history sane even if an ended/skipped
|
|
/// is lost.
|
|
class EventsApi {
|
|
EventsApi(this._dio);
|
|
final Dio _dio;
|
|
|
|
/// POST play_started. Returns the server's play_event_id (used to
|
|
/// close the row later), or null if the call failed / response was
|
|
/// malformed. `source` tags the originating system playlist
|
|
/// ('for_you' | 'discover') so the server advances that rotation;
|
|
/// omit for library / user-playlist / radio plays.
|
|
Future<String?> playStarted({
|
|
required String trackId,
|
|
required String clientId,
|
|
String? source,
|
|
}) async {
|
|
final r = await _dio.post<Map<String, dynamic>>(
|
|
'/api/events',
|
|
data: {
|
|
'type': 'play_started',
|
|
'track_id': trackId,
|
|
'client_id': clientId,
|
|
if (source != null && source.isNotEmpty) 'source': source,
|
|
},
|
|
);
|
|
return (r.data ?? const {})['play_event_id'] as String?;
|
|
}
|
|
|
|
Future<void> playEnded({
|
|
required String playEventId,
|
|
required int durationPlayedMs,
|
|
}) async {
|
|
await _dio.post<void>(
|
|
'/api/events',
|
|
data: {
|
|
'type': 'play_ended',
|
|
'play_event_id': playEventId,
|
|
'duration_played_ms': durationPlayedMs,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> playSkipped({
|
|
required String playEventId,
|
|
required int positionMs,
|
|
}) async {
|
|
await _dio.post<void>(
|
|
'/api/events',
|
|
data: {
|
|
'type': 'play_skipped',
|
|
'play_event_id': playEventId,
|
|
'position_ms': positionMs,
|
|
},
|
|
);
|
|
}
|
|
}
|