Files
plezy/lib/services/playback_report_session.dart
T

270 lines
8.3 KiB
Dart

import 'dart:async';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
enum _PlaybackReportState { idle, starting, started, stopping, stopFailed, stopped }
class _PendingProgressReport {
_PendingProgressReport(this.snapshot);
final PlaybackReportSnapshot snapshot;
final Completer<bool> completer = Completer<bool>();
void complete(bool value) {
if (!completer.isCompleted) completer.complete(value);
}
void completeError(Object error, StackTrace stackTrace) {
if (!completer.isCompleted) completer.completeError(error, stackTrace);
}
}
class PlaybackStreamSelection {
final String? mediaSourceId;
final int? audioStreamIndex;
final int? subtitleStreamIndex;
const PlaybackStreamSelection({this.mediaSourceId, this.audioStreamIndex, this.subtitleStreamIndex});
static const none = PlaybackStreamSelection();
}
typedef PlaybackStreamSelectionResolver = FutureOr<PlaybackStreamSelection> Function();
class PlaybackReportSnapshot {
final String state;
final Duration position;
final Duration duration;
final PlaybackReportMetadata report;
final PlaybackStreamSelectionResolver resolveStreamSelection;
const PlaybackReportSnapshot({
required this.state,
required this.position,
required this.duration,
this.report = const PlaybackReportMetadata.live(),
this.resolveStreamSelection = _noStreamSelection,
});
bool get isStopped => state == 'stopped';
static PlaybackStreamSelection _noStreamSelection() => PlaybackStreamSelection.none;
}
/// Serializes backend playback-report calls for one media item.
///
/// This class owns the start/progress/stop lifecycle invariants. Callers may
/// fire reports concurrently, but state changes are recorded synchronously
/// before any async work such as settings lookup, track mapping, or HTTP calls.
class PlaybackReportSession {
PlaybackReportSession({required this.client, required this.itemId, this.playSessionId, this.playMethod});
final MediaServerClient client;
final String itemId;
final String? playSessionId;
final String? playMethod;
_PlaybackReportState _state = _PlaybackReportState.idle;
PlaybackReportSnapshot? _startSnapshot;
_PendingProgressReport? _pendingProgress;
Future<void>? _pumpFuture;
Future<void>? _stopFuture;
bool get isIdle => _state == _PlaybackReportState.idle;
bool get isStopped => _state == _PlaybackReportState.stopped;
void resetAfterStop() {
if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) {
_state = _PlaybackReportState.idle;
_startSnapshot = null;
_discardPendingProgress();
_pumpFuture = null;
_stopFuture = null;
}
}
bool get _isStoppingOrTerminal =>
_state == _PlaybackReportState.stopping ||
_state == _PlaybackReportState.stopFailed ||
_state == _PlaybackReportState.stopped;
Future<bool> report(PlaybackReportSnapshot snapshot) {
return snapshot.isStopped ? _reportStopped(snapshot) : _reportProgress(snapshot);
}
Future<bool> _reportProgress(PlaybackReportSnapshot snapshot) {
if (_isStoppingOrTerminal) {
return Future.value(false);
}
switch (_state) {
case _PlaybackReportState.idle:
_state = _PlaybackReportState.starting;
_startSnapshot = snapshot;
return _ensurePump().then((_) => true);
case _PlaybackReportState.starting:
// A duplicate playing heartbeat during startup does not need an
// immediate progress ping; a state change (playing -> paused) does.
if (_startSnapshot?.state != snapshot.state) {
return _setPendingProgress(snapshot);
}
final pump = _pumpFuture;
return (pump ?? Future<void>.value()).then((_) => true);
case _PlaybackReportState.started:
final pending = _setPendingProgress(snapshot);
_ensurePump();
return pending;
case _PlaybackReportState.stopping:
case _PlaybackReportState.stopFailed:
case _PlaybackReportState.stopped:
return Future.value(false);
}
}
Future<bool> _reportStopped(PlaybackReportSnapshot snapshot) {
if (_state == _PlaybackReportState.stopped) {
return Future.value(false);
}
if (_state == _PlaybackReportState.stopping) {
final pending = _stopFuture;
return (pending ?? Future<void>.value()).then((_) => false);
}
_state = _PlaybackReportState.stopping;
_discardPendingProgress();
final stopFuture = _runStop(snapshot);
_stopFuture = stopFuture;
return stopFuture.then((_) => true);
}
Future<void> _ensurePump() {
final existing = _pumpFuture;
if (existing != null) return existing;
final future = _runPump();
_pumpFuture = future;
future.then(
(_) {
if (identical(_pumpFuture, future)) _pumpFuture = null;
},
onError: (Object error, StackTrace stackTrace) {
if (identical(_pumpFuture, future)) _pumpFuture = null;
},
);
return future;
}
Future<void> _runPump() async {
try {
final start = _startSnapshot;
if (start != null) {
await _sendStarted(start);
_startSnapshot = null;
if (_state == _PlaybackReportState.starting) {
_state = _PlaybackReportState.started;
}
}
while (_state == _PlaybackReportState.started) {
final progress = _pendingProgress;
if (progress == null) break;
_pendingProgress = null;
try {
progress.complete(await _sendProgress(progress.snapshot));
} catch (e, st) {
progress.completeError(e, st);
rethrow;
}
}
} catch (_) {
if (_state == _PlaybackReportState.starting) {
_state = _PlaybackReportState.idle;
_startSnapshot = null;
}
_discardPendingProgress();
rethrow;
}
}
Future<void> _runStop(PlaybackReportSnapshot snapshot) async {
var stopSucceeded = false;
try {
final pump = _pumpFuture;
if (pump != null) {
try {
await pump;
} catch (_) {
// Stop is terminal and best-effort. A failed start/progress report
// must not prevent the final stopped position from being reported.
}
}
await _sendStopped(snapshot);
stopSucceeded = true;
} finally {
_stopFuture = null;
_discardPendingProgress();
_pumpFuture = null;
_state = stopSucceeded ? _PlaybackReportState.stopped : _PlaybackReportState.stopFailed;
}
}
Future<bool> _setPendingProgress(PlaybackReportSnapshot snapshot) {
_discardPendingProgress();
final pending = _PendingProgressReport(snapshot);
_pendingProgress = pending;
return pending.completer.future;
}
void _discardPendingProgress() {
final pending = _pendingProgress;
if (pending == null) return;
_pendingProgress = null;
pending.complete(false);
}
Future<void> _sendStarted(PlaybackReportSnapshot snapshot) async {
final selection = await snapshot.resolveStreamSelection();
await client.reportPlaybackStarted(
itemId: itemId,
position: snapshot.position,
duration: snapshot.duration,
playSessionId: playSessionId,
playMethod: playMethod,
mediaSourceId: selection.mediaSourceId,
audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
);
}
Future<bool> _sendProgress(PlaybackReportSnapshot snapshot) async {
final selection = await snapshot.resolveStreamSelection();
if (_state != _PlaybackReportState.started) return false;
await client.reportPlaybackProgress(
itemId: itemId,
position: snapshot.position,
duration: snapshot.duration,
isPaused: snapshot.state == 'paused',
playSessionId: playSessionId,
playMethod: playMethod,
mediaSourceId: selection.mediaSourceId,
audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
);
return true;
}
Future<void> _sendStopped(PlaybackReportSnapshot snapshot) async {
final selection = await snapshot.resolveStreamSelection();
await client.reportPlaybackStopped(
itemId: itemId,
position: snapshot.position,
duration: snapshot.duration,
playSessionId: playSessionId,
mediaSourceId: selection.mediaSourceId,
report: snapshot.report,
);
}
}