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 completer = Completer(); 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 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, this.liveStreamId, }); final MediaServerClient client; final String itemId; final String? playSessionId; final String? playMethod; final String? liveStreamId; _PlaybackReportState _state = _PlaybackReportState.idle; PlaybackReportSnapshot? _startSnapshot; _PendingProgressReport? _pendingProgress; Future? _pumpFuture; Future? _stopFuture; bool _resetAfterStopRequested = false; bool get isIdle => _state == _PlaybackReportState.idle; bool get isStopped => _state == _PlaybackReportState.stopped; void resetAfterStop() { if (_state == _PlaybackReportState.stopping) { _resetAfterStopRequested = true; return; } if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) { _state = _PlaybackReportState.idle; _startSnapshot = null; _resetAfterStopRequested = false; _discardPendingProgress(); _pumpFuture = null; _stopFuture = null; } } bool get _isStoppingOrTerminal => _state == _PlaybackReportState.stopping || _state == _PlaybackReportState.stopFailed || _state == _PlaybackReportState.stopped; Future report(PlaybackReportSnapshot snapshot) { return snapshot.isStopped ? _reportStopped(snapshot) : _reportProgress(snapshot); } Future _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.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 _reportStopped(PlaybackReportSnapshot snapshot) { if (_state == _PlaybackReportState.stopped) { return Future.value(false); } if (_state == _PlaybackReportState.stopping) { final pending = _stopFuture; return (pending ?? Future.value()).then((_) => false); } _state = _PlaybackReportState.stopping; _discardPendingProgress(); final stopFuture = _runStop(snapshot); _stopFuture = stopFuture; return stopFuture.then((_) => true); } Future _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 _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 _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 { final shouldReset = _resetAfterStopRequested; _resetAfterStopRequested = false; _stopFuture = null; _discardPendingProgress(); _pumpFuture = null; _state = shouldReset ? _PlaybackReportState.idle : stopSucceeded ? _PlaybackReportState.stopped : _PlaybackReportState.stopFailed; } } Future _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 _sendStarted(PlaybackReportSnapshot snapshot) async { final selection = await snapshot.resolveStreamSelection(); await client.reportPlaybackStarted( itemId: itemId, position: snapshot.position, duration: snapshot.duration, playSessionId: playSessionId, playMethod: playMethod, liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, audioStreamIndex: selection.audioStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex, ); } Future _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, liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, audioStreamIndex: selection.audioStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex, ); return true; } Future _sendStopped(PlaybackReportSnapshot snapshot) async { final selection = await snapshot.resolveStreamSelection(); await client.reportPlaybackStopped( itemId: itemId, position: snapshot.position, duration: snapshot.duration, playSessionId: playSessionId, liveStreamId: liveStreamId, mediaSourceId: selection.mediaSourceId, report: snapshot.report, ); } }