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>
92 lines
2.7 KiB
Dart
92 lines
2.7 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,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Replays a complete play that happened offline / on a flaky
|
|
/// connection (#426 part B). One call: the server records start+end
|
|
/// from `atIso` (the original play-start time) + durationPlayedMs,
|
|
/// applies the canonical skip rule, and advances #415 rotation when
|
|
/// source is a system playlist. Driven by the offline mutation
|
|
/// queue, never the live path.
|
|
Future<void> playOffline({
|
|
required String trackId,
|
|
required String clientId,
|
|
required String atIso,
|
|
required int durationPlayedMs,
|
|
String? source,
|
|
}) async {
|
|
await _dio.post<void>(
|
|
'/api/events',
|
|
data: {
|
|
'type': 'play_offline',
|
|
'track_id': trackId,
|
|
'client_id': clientId,
|
|
'at': atIso,
|
|
'duration_played_ms': durationPlayedMs,
|
|
if (source != null && source.isNotEmpty) 'source': source,
|
|
},
|
|
);
|
|
}
|
|
}
|