import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting; import 'package:flutter/services.dart'; import '../../media/media_display_criteria.dart'; import '../../utils/app_logger.dart'; import '../../utils/track_label_builder.dart'; import '../font_loader.dart'; import '../models.dart'; import 'mpv_node_decoder.dart'; import 'audio_rendering_mode.dart'; import 'player.dart'; import 'player_state.dart'; import 'player_stream_controllers.dart'; import 'player_streams.dart'; /// Abstract base class for player implementations. /// /// This class contains shared logic for both [PlayerAndroid] (ExoPlayer) /// and [PlayerNative] (MPV) implementations, including: /// - State management /// - Stream controller setup /// - Event handling infrastructure /// - Property change handlers /// - Track parsing and selection /// - Common lifecycle methods abstract class PlayerBase with PlayerStreamControllersMixin implements Player { PlayerState _state = const PlayerState(); @override PlayerState get state => _state; @override Duration get currentPosition => Duration(milliseconds: _positionMs); @override bool get audioPassthroughActive => false; /// Gapless-audio arming — meaningful only on the audio players, which /// override this. Video backends ignore it. @override Future setNext(Media? media) async {} late final PlayerStreams _streams; @override PlayerStreams get streams => _streams; final ValueNotifier _textureId = ValueNotifier(null); @override int? get textureId => _textureId.value; ValueListenable get textureIdListenable => _textureId; @protected void setTextureId(int? value) { if (!_disposed) _textureId.value = value; } StreamSubscription? _eventSubscription; StreamSubscription? _logSubscription; bool _disposed = false; late final Future? _nativeOwnershipReady; final Completer _nativeRelease = Completer(); final _throttleSw = Stopwatch()..start(); int _lastEmitMs = 0; int _lastCacheStateMs = 0; int _positionMs = 0; /// Overlapping-seek bookkeeping. Each [runSeek] optimistically writes its own /// target, so only the last of a group to settle can tell where the backend /// actually left the playhead — and because the backend applies commands in /// the order they were issued, "which one landed" is decided by request /// order, not by which reply came back first. /// /// A playhead move from outside [runSeek] detaches the active group: it is /// newer information than any of that group's outcomes, and seeks starting /// after it belong to a fresh group anchored where it left the playhead. /// Issue-time id for anything that asks the playhead to move — a seek /// request, a relocation claim, a source install. Monotonic, so it also /// orders [runSeek] calls inside a group. int _playheadOperations = 0; /// The newest operation the backend actually accepted. A completion may only /// speak for the playhead while nothing newer has been accepted; asking is /// not owning, so a rejected request never silences an accepted one. int _acceptedOperation = 0; /// A relocation whose destination arrived while a newer seek was still in /// flight. Held rather than published: that seek will define the playhead if /// it lands, and this is still the truth if it does not. ({int token, Duration? position})? _deferredRelocation; /// Which operation last wrote a position into state. Compared by identity, /// not by value: a successor seeking to the same timestamp is still a /// different write and must not be repaired away by its predecessor. int _lastPositionWriter = 0; /// Stands in for the backend in [_lastPositionWriter]: a reported position is /// authoritative and beats anything Dart wrote optimistically, whatever its /// value happens to be. static const int _backendReportedWriter = -1; _SeekGroup? _activeSeekGroup; @override Duration? get outgoingSourcePosition => _outgoingSourcePosition; Duration? _outgoingSourcePosition; /// The last position the backend reported *for the source now playing*, as /// distinct from [_positionMs], which also carries Dart's optimistic writes. /// Only an observation can say where a source actually got to, and an /// observation of one source says nothing about the next — so installing a /// source clears this rather than letting it carry over. int _lastReportedPositionMs = 0; /// Set by [freezeOutgoingSourcePosition] when a source boundary is seen on /// the same flow that carries position reports. Preferred by /// [takeSourceOwnership], which cannot make that ordering guarantee itself. int? _frozenOutgoingPositionMs; /// Wall-clock slack added to a seek's flight time before converting to media /// time, absorbing tick granularity and clock jitter. static const _landedProgressSlack = Duration(milliseconds: 250); /// Claim the playhead for a relocation whose destination the backend has not /// reported yet, and take the token that says so. /// /// Claiming is what makes two overlapping relocations distinguishable: /// reading a shared revision would let them both think they still speak for /// the playhead. /// /// Claiming alone changes nothing else. A command that is then rejected never /// moved the playhead, so an in-flight seek group stays authoritative and /// reconciles its own outcome. A command that is accepted hands the token to /// [commitPlayheadRelocation], which takes ownership even when the /// destination cannot be read back, and then to [publishPlayheadRelocation] /// when there is a destination to report. @protected int beginPlayheadRelocation() => ++_playheadOperations; /// Record that a claimed relocation actually moved the playhead, even though /// its destination could not be read back. /// /// Unknown movement is still movement: an in-flight seek group settling /// afterwards must not roll back across it, so ownership passes here while /// the position itself waits for the backend's next tick. @protected void commitPlayheadRelocation(int token) { if (_disposed || token < _acceptedOperation) return; _takeOperationOwnership(token); // A newer seek is still in flight: keep its group so it can arbitrate // against this relocation when it resolves. Recorded without a destination, // because an accepted command moved the playhead whether or not its // position can be read — the group must not roll back across it. if (_hasUnresolvedSeekNewerThan(token)) { _deferredRelocation = (token: token, position: null); return; } // The claim already made this token the owner; taking the playhead from the // seek group is all that is left. The token stays valid so the same // relocation can still publish a destination once it reads one back. _activeSeekGroup = null; } Duration? _timelineDuration; int _nextPropId = 0; final Map _propIdToName = {}; Map> _externalSubtitleMetadataByUri = const {}; bool _primaryMediaLoadStarted = false; bool _primaryMediaReadyEmitted = false; @visibleForTesting static Duration debugNativeOwnershipDisposeTimeout = const Duration(seconds: 3); static const _maximumDurationMilliseconds = 9223372036854775; static double? _finiteDouble(Object? value) { if (value is! num) return null; final result = value.toDouble(); return result.isFinite ? result : null; } static int? _millisecondsFromSeconds(Object? value, {bool round = false}) { final seconds = _finiteDouble(value); if (seconds == null) return null; final milliseconds = seconds * Duration.millisecondsPerSecond; if (!milliseconds.isFinite || milliseconds < -_maximumDurationMilliseconds || milliseconds > _maximumDurationMilliseconds) { return null; } return round ? milliseconds.round() : milliseconds.toInt(); } static int? _finiteInt(Object? value) { if (value is int) return value; final result = _finiteDouble(value); if (result == null || result < -9007199254740991 || result > 9007199254740991) return null; return result.toInt(); } @protected bool initialized = false; @override bool get disposed => _disposed; MethodChannel get methodChannel; EventChannel get eventChannel; String get logPrefix; PlayerBase() { _nativeOwnershipReady = _eventChannelOwners[eventChannel.name]?._nativeRelease.future; _streams = createStreams(); _setupEventListener(); _logSubscription = logController.stream.listen(_forwardToAppLogger); } void _forwardToAppLogger(PlayerLog log) { final message = '[$logPrefix:${log.prefix}] ${log.text}'.trimRight(); switch (log.level) { case PlayerLogLevel.fatal: case PlayerLogLevel.error: appLogger.e(message); case PlayerLogLevel.warn: appLogger.w(message); case PlayerLogLevel.info: case PlayerLogLevel.verbose: appLogger.i(message); case PlayerLogLevel.debug: case PlayerLogLevel.trace: appLogger.d(message); case PlayerLogLevel.none: break; } } /// Backends expose static per-backend [EventChannel]s, so two overlapping /// instances (episode handoff, quick exit/reopen) share one channel name. /// The engine allows a single active stream per channel: a newer instance's /// listen displaces the older sink, and the older instance's late cancel /// would then tear down the *newer* stream — leaving it eventless — while /// the final cancel gets an engine "No active stream to cancel" error. /// Only the instance recorded here may send the native cancel. static final Map _eventChannelOwners = {}; void _setupEventListener() { _eventChannelOwners[eventChannel.name] = this; _eventSubscription = eventChannel.receiveBroadcastStream().listen( _handleEvent, onError: (error) { if (_disposed) return; errorController.add(PlayerError(error.toString())); }, ); } /// The (name, format) registrations every backend makes at init — the /// properties [handlePropertyChange] needs for core [PlayerState]. /// `track-list` is registered separately because mpv uses node format on /// Apple platforms; backend-specific extras (mpv: secondary-sid / /// demuxer-cache-state / audio-device*; ExoPlayer: demuxer-cache-time) /// are appended by the subclasses. static const List<(String, String)> corePropertyObservations = [ ('time-pos', 'double'), ('duration', 'double'), ('seekable', 'flag'), ('pause', 'flag'), ('paused-for-cache', 'flag'), ('eof-reached', 'flag'), ('volume', 'double'), ('speed', 'double'), ('aid', 'string'), ('sid', 'string'), ]; /// Register [corePropertyObservations] plus `track-list` in the /// backend's preferred format. Called from each subclass's initialize. @protected Future observeCoreProperties({required String trackListFormat}) async { for (final (name, format) in corePropertyObservations) { await observeProperty(name, format); } await observeProperty('track-list', trackListFormat); } @protected Future observeProperty(String name, String format) async { final propId = _nextPropId++; _propIdToName[propId] = name; await invoke('observeProperty', {'name': name, 'format': format, 'id': propId}); } void _handleEvent(dynamic event) { if (_disposed) return; if (event is List && event.length == 2) { final propertyId = event.first; if (propertyId is! int) return; final name = _propIdToName[propertyId]; if (name != null) { handlePropertyChange(name, event[1]); } } else if (event is Map) { final type = event['type']; final name = event['name']; if (type == 'event' && name is String) { final rawData = event['data']; handlePlayerEvent(name, rawData is Map ? rawData : null); } } } void handlePropertyChange(String name, dynamic value) { if (_disposed) return; switch (name) { case 'pause': final playing = value == false; _state = _state.copyWith(playing: playing); playingController.add(playing); break; case 'eof-reached': final completed = value == true; _state = _state.copyWith(completed: completed); completedController.add(completed); break; case 'paused-for-cache': final buffering = value == true; _state = _state.copyWith(buffering: buffering); bufferingController.add(buffering); break; case 'time-pos': final positionMs = _millisecondsFromSeconds(value, round: true); if (positionMs != null) { final pos = Duration(milliseconds: positionMs); _positionMs = positionMs; // The backend has spoken, so no Dart-side optimistic write is on top // any more — whatever the value happens to be. _lastPositionWriter = _backendReportedWriter; _lastReportedPositionMs = positionMs; // Only allocate PlayerState + emit at ~4Hz (250ms). The raw integer // remains current for synchronous position reads on every tick. final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastEmitMs >= 250) { _lastEmitMs = nowMs; _state = _state.copyWith(position: pos); positionController.add(pos); } } break; case 'duration': final durationMs = _millisecondsFromSeconds(value); if (durationMs != null) { final duration = _timelineDuration ?? Duration(milliseconds: durationMs); _state = _state.copyWith(duration: duration); durationController.add(duration); } break; case 'seekable': if (value is bool) { setSeekable(value); } break; case 'demuxer-cache-time': final bufferMs = _millisecondsFromSeconds(value); if (bufferMs != null) { final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) break; _lastCacheStateMs = nowMs; final buffer = Duration(milliseconds: bufferMs); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); // Synthesize a single range for players without demuxer-cache-state (ExoPlayer). // ExoPlayer only buffers ahead of the current position, so use position as start. final ranges = [BufferRange(start: _state.position, end: buffer)]; _state = _state.copyWith(bufferRanges: ranges); bufferRangesController.add(ranges); } break; case 'demuxer-cache-state': _handleDemuxerCacheState(value); break; case 'volume': final volume = _finiteDouble(value); if (volume != null) { setVolumeState(volume); } break; case 'speed': final rate = _finiteDouble(value); if (rate != null) { _state = _state.copyWith(rate: rate); rateController.add(rate); } break; case 'track-list': final trackList = MpvNodeDecoder.decodeList(value); if (trackList != null) { if (_primaryMediaLoadStarted && !_primaryMediaReadyEmitted && _hasPrimaryMediaTrack(trackList)) { _primaryMediaReadyEmitted = true; primaryMediaReadyController.add(null); } final result = parseTrackList(trackList); _state = _state.copyWith(tracks: result.tracks); tracksController.add(result.tracks); // Derive selection from mpv's "selected" field in the track data. // This is the source of truth and handles cases where aid/sid // values don't match track IDs (e.g. "auto", "0", "no"). if (result.selectedAudioId != null) { updateSelectedAudioTrack(result.selectedAudioId); } if (result.selectedSubtitleId != null) { updateSelectedSubtitleTrack(result.selectedSubtitleId); } } break; case 'aid': updateSelectedAudioTrack(value); break; case 'sid': updateSelectedSubtitleTrack(value); break; case 'secondary-sid': updateSelectedSecondarySubtitleTrack(value); break; case 'audio-device-list': final deviceList = MpvNodeDecoder.decodeList(value); if (deviceList != null) { final devices = []; for (final entry in deviceList) { if (entry is! Map) continue; final name = entry['name']; final description = entry['description']; if (name is! String) continue; devices.add(AudioDevice(name: name, description: description is String ? description : '')); } _state = _state.copyWith(audioDevices: devices); audioDevicesController.add(devices); } break; case 'audio-device': if (value is String && value.isNotEmpty) { final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value); _state = _state.copyWith(audioDevice: device); audioDeviceController.add(device); } break; } } /// Parse demuxer-cache-state property to extract seekable ranges and buffer end. void _handleDemuxerCacheState(dynamic value) { if (value is String && value.isNotEmpty) { // Throttle JSON parsing to avoid ANR on low-end devices final nowMs = _throttleSw.elapsedMilliseconds; if (nowMs - _lastCacheStateMs < 250) return; _lastCacheStateMs = nowMs; } final cacheState = MpvNodeDecoder.decodeMap(value); if (cacheState == null) return; // Extract cache-end for the single buffer duration (replaces demuxer-cache-time) final cacheEndMs = _millisecondsFromSeconds(cacheState['cache-end']); if (cacheEndMs != null) { final buffer = Duration(milliseconds: cacheEndMs); _state = _state.copyWith(buffer: buffer); bufferController.add(buffer); } // Extract seekable-ranges array final seekableRanges = cacheState['seekable-ranges']; if (seekableRanges is List) { final ranges = []; for (final range in seekableRanges) { if (range is! Map) continue; final startMs = _millisecondsFromSeconds(range['start']); final endMs = _millisecondsFromSeconds(range['end']); if (startMs != null && endMs != null) { ranges.add( BufferRange( start: Duration(milliseconds: startMs), end: Duration(milliseconds: endMs), ), ); } } _state = _state.copyWith(bufferRanges: ranges); bufferRangesController.add(ranges); } } void handlePlayerEvent(String name, Map? data) { if (_disposed) return; switch (name) { case 'start-file': _primaryMediaLoadStarted = true; _primaryMediaReadyEmitted = false; fileStartedController.add(null); break; case 'end-file': _primaryMediaLoadStarted = false; setSeekable(false); final rawReason = data?['reason']; final reason = switch (rawReason) { 0 => 'eof', 2 => 'stop', 3 => 'quit', 4 => 'error', 5 => 'redirect', final String s => s, _ => null, }; if (reason == 'eof') { _state = _state.copyWith(completed: true); completedController.add(true); } else if (reason == 'error') { fileLoadFailedController.add(null); final rawMessage = data?['message']; final rawCause = data?['cause']; errorController.add( PlayerError( rawMessage is String ? rawMessage : 'Playback error', cause: rawCause is String ? rawCause : null, ), ); } break; case 'file-loaded': _state = _state.copyWith(completed: false); completedController.add(false); fileLoadedController.add(null); break; case 'playback-restart': playbackRestartController.add(null); break; case 'log-message': final rawPrefix = data?['prefix']; final rawLevel = data?['level']; final rawText = data?['text']; final prefix = rawPrefix is String ? rawPrefix : ''; final level = parseLogLevel(rawLevel is String ? rawLevel : 'info'); final text = rawText is String ? rawText : ''; logController.add(PlayerLog(level: level, prefix: prefix, text: text)); break; } } bool _hasPrimaryMediaTrack(List trackList) { for (final track in trackList) { if (track is! Map || track['external'] == true) continue; final type = track['type']; if (type == 'audio') return true; // mpv exposes embedded/external cover art as a video track. It is not // evidence that the primary audio file has finished discovery, so it // must not start the external-sidecar timeout on its own. if (type == 'video' && track['albumart'] != true) return true; } return false; } PlayerLogLevel parseLogLevel(String level) { return switch (level) { 'fatal' => PlayerLogLevel.fatal, 'error' => PlayerLogLevel.error, 'warn' => PlayerLogLevel.warn, 'info' => PlayerLogLevel.info, 'v' || 'verbose' => PlayerLogLevel.verbose, 'debug' => PlayerLogLevel.debug, 'trace' => PlayerLogLevel.trace, _ => PlayerLogLevel.info, }; } ({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) { final audioTracks = []; final subtitleTracks = []; String? selectedAudioId; String? selectedSubtitleId; final containerMetadataIndexes = {}; for (final track in trackList) { if (track is! Map) continue; final rawType = track['type']; if (rawType is! String) continue; final type = rawType; final rawId = track['id']; final id = rawId is String || rawId is num ? rawId.toString() : ''; final selected = track['selected'] == true; if (type == 'audio') { final rawExternalFilename = track['external-filename']; final externalFilename = rawExternalFilename is String ? rawExternalFilename : null; final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename]; // Container sidecars are opened only to expose their subtitle tracks. // Do not let their audio streams participate in normal track matching. if (externalMetadata?.any((metadata) => metadata.isContainer) == true) continue; if (selected) selectedAudioId = id; audioTracks.add( AudioTrack( id: id, title: cleanTrackMetadataValue(track['title'] is String ? track['title'] as String : null), language: cleanTrackMetadataValue(track['lang'] is String ? track['lang'] as String : null), codec: track['codec'] is String ? track['codec'] as String : null, channels: _finiteInt(track['demux-channel-count']), sampleRate: _finiteInt(track['demux-samplerate']), isDefault: track['default'] == true, ), ); } else if (type == 'sub') { if (selected) selectedSubtitleId = id; final rawCodec = track['codec']; final codec = rawCodec is String ? rawCodec : null; final rawTitle = track['title']; final rawLanguage = track['lang']; final rawExternalFilename = track['external-filename']; final externalFilename = rawExternalFilename is String ? rawExternalFilename : null; final externalMetadata = externalFilename == null ? null : _externalSubtitleMetadataByUri[externalFilename]; final isContainer = track['container'] == true || externalMetadata?.any((metadata) => metadata.isContainer) == true; SubtitleTrack? matchedMetadata; if (externalMetadata != null && externalMetadata.isNotEmpty) { if (isContainer && externalFilename != null) { final metadataIndex = containerMetadataIndexes[externalFilename] ?? 0; containerMetadataIndexes[externalFilename] = metadataIndex + 1; if (metadataIndex < externalMetadata.length && externalMetadata[metadataIndex].isContainer) { matchedMetadata = externalMetadata[metadataIndex]; } } else { matchedMetadata = externalMetadata.first; } } subtitleTracks.add( SubtitleTrack( id: id, // mpv may synthesize a container track title from the signed // source filename. Source-catalog metadata is both safer and more // accurate there, including on builds that drop disposition flags. // Ordinary sidecars still fall back to metadata reported by mpv. title: isContainer ? matchedMetadata?.title : matchedMetadata?.title ?? cleanSubtitleTitle(rawTitle is String ? rawTitle : null, codec: codec), language: isContainer ? matchedMetadata?.language : matchedMetadata?.language ?? cleanTrackMetadataValue(rawLanguage is String ? rawLanguage : null), codec: matchedMetadata?.codec ?? codec, isDefault: matchedMetadata?.isDefault ?? (track['default'] == true), isForced: matchedMetadata?.isForced ?? (track['forced'] == true), isExternal: track['external'] == true, isContainer: isContainer, uri: externalFilename, ), ); } } return ( tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks), selectedAudioId: selectedAudioId, selectedSubtitleId: selectedSubtitleId, ); } void updateSelectedAudioTrack(dynamic trackId) { final id = trackId?.toString(); final selectedTrack = (id == null || id == 'no') ? null : _state.tracks.audio.firstWhereOrNull((track) => track.id == id); if (id != null && id != 'no' && selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack)); trackController.add(_state.track); } void updateSelectedSubtitleTrack(dynamic trackId) { final id = trackId?.toString(); final selectedTrack = (id == null || id == 'no') ? SubtitleTrack.off : _state.tracks.subtitle.firstWhereOrNull((track) => track.id == id); if (selectedTrack == null) return; _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack)); trackController.add(_state.track); } void updateSelectedSecondarySubtitleTrack(dynamic trackId) { final id = trackId?.toString(); SubtitleTrack? selectedTrack; if (id == null || id == 'no') { selectedTrack = null; } else { selectedTrack = _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id); } _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack)); trackController.add(_state.track); } @protected void clearTracks() { const empty = Tracks(); _state = _state.copyWith(tracks: empty, track: const TrackSelection()); tracksController.add(empty); } @protected void setExternalSubtitleMetadata(List? externalSubtitles) { final metadataByUri = >{}; for (final subtitle in externalSubtitles ?? const []) { final uri = subtitle.uri; if (uri != null && uri.isNotEmpty) { (metadataByUri[uri] ??= []).add(subtitle); } } _externalSubtitleMetadataByUri = metadataByUri; } @protected Map> snapshotExternalSubtitleMetadata() => Map>.of(_externalSubtitleMetadataByUri); @protected void restoreExternalSubtitleMetadata(Map> snapshot) { _externalSubtitleMetadataByUri = Map>.of(snapshot); } @protected void setVolumeState(double volume) { if (_state.volume == volume) return; _state = _state.copyWith(volume: volume); volumeController.add(volume); } @protected void setSeekable(bool seekable) { if (_state.seekable == seekable) return; _state = _state.copyWith(seekable: seekable); seekableController.add(seekable); } @protected void configureTimeline({Duration? duration}) { _timelineDuration = duration; } /// Report that something is moving the playhead discontinuously, to [target] /// — or somewhere only the backend knows, when [target] is null. Announced /// when the move is requested, so it is intent rather than an observed /// landing; see `PlayerStreams.playheadJump`. /// /// Guards disposal: several callers announce after an await, by which point /// the controllers may already be closed. @protected void announcePlayheadJump(Duration? target) { if (_disposed) return; playheadJumpController.add(target); } /// Publish a playhead position the backend chose for itself, after a command /// that relocates it without going through [runSeek]. /// /// Writes state as well as announcing: `PlayerState.position` is what /// consumers rebase relative seeks from, and its tick updates are throttled, /// so announcing alone would leave them working off the pre-command position. /// /// Arbitration is by acceptance, not by request: a seek that has only been /// asked for does not invalidate a relocation the backend already took, and a /// destination arriving while a newer seek is still unresolved is held rather /// than published — publishing would read as a foreign jump to whoever is /// coalescing that seek, and discarding would lose the cue if it is rejected. @protected void publishPlayheadRelocation(Duration position, {int? token}) { if (_disposed) return; // Something newer has claimed or moved the playhead since this relocation // started, so its answer is stale. if (token != null && token < _acceptedOperation) return; // A newer seek is still in flight, so it — not this answer about the past — // will define where the playhead ends up if it lands. Publishing now would // read as a foreign jump to whoever is coalescing that seek and cost them // the burst; discarding would lose the cue if that seek is then rejected. // Hold it until the group resolves. if (token != null && _hasUnresolvedSeekNewerThan(token)) { _deferredRelocation = (token: token, position: position); return; } final writer = token ?? ++_playheadOperations; _takeOperationOwnership(writer); _lastPositionWriter = writer; _activeSeekGroup = null; _setPlaybackPosition(position); announcePlayheadJump(position); } /// Where the playhead is, given [target] was accepted [since] ago. /// /// A reported position at or shortly past the target is playback running on /// from it and is fresher than the target itself; anything else is a stale /// observation from before the seek. "Shortly" is the media time the elapsed /// wall clock could actually cover at the current rate — a fixed window would /// rewind real progress at 8x and preserve stale ticks in slow motion — and a /// paused player covers none at all. Duration _progressedFrom(Duration target, Stopwatch? since) { // Only the backend observes playback. `_positionMs` also carries Dart's own // optimistic writes, and a rejected request's target sitting a few // milliseconds past an accepted one is not progress — reading it as such // would keep the position the backend refused. if (_lastPositionWriter != _backendReportedWriter) return target; final observed = Duration(milliseconds: _positionMs); final drift = observed - target; if (drift.isNegative) return target; final rate = _state.rate.isFinite && _state.rate > 0 ? _state.rate : 1.0; final elapsed = since?.elapsedMicroseconds ?? 0; final covered = _state.playing ? Duration(microseconds: ((elapsed + _landedProgressSlack.inMicroseconds) * rate).round()) : Duration.zero; return drift <= covered ? observed : target; } /// Mark [operation] as the newest thing the backend accepted, retiring any /// relocation that was waiting to see whether it would land. void _takeOperationOwnership(int operation) { _acceptedOperation = operation; if ((_deferredRelocation?.token ?? operation) < operation) _deferredRelocation = null; } /// Settle a held relocation now that the group arbitrating it has drained. /// /// It wins when it is newer than anything that group landed; otherwise the /// group's own outcome stands and the cue is history either way. bool _resolveDeferredRelocation(_SeekGroup group) { final deferred = _deferredRelocation; _deferredRelocation = null; if (deferred == null || _disposed || deferred.token <= group.landedRequest) return false; _takeOperationOwnership(deferred.token); // Winning without a destination still means the group must not undo itself // across this relocation; there is simply nothing new to publish, and the // backend's next tick supplies the position. final position = deferred.position; if (position != null) { _setPlaybackPosition(position); announcePlayheadJump(position); return true; } // Destination unknown, and suppressing the group's rollback would leave a // rejected request's optimistic target on top of state — certainly not // where an accepted relocation put the playhead. _repairRejectedWrite(group); // The null this relocation sent before dispatch predates the seek it was // waiting on, so that seek's own echo has since re-armed any coalescing // consumer. Say again that the playhead is somewhere they did not put it. announcePlayheadJump(null); return true; } /// Record where the playing source got to, before its successor can report /// anything. /// /// [takeSourceOwnership] runs from the backend's event flow, which is not /// ordered against the property flow carrying position reports, so by then an /// incoming report may already have replaced the outgoing one. Callers that /// observe the boundary *on the property flow* can close that window by /// calling this; whoever finalises the outgoing item then gets its real last /// position instead of its successor's first. @protected void freezeOutgoingSourcePosition() { if (_disposed) return; _frozenOutgoingPositionMs = _lastReportedPositionMs; } /// The handover this froze a position for is not going to happen. Drop it, or /// a later advance whose boundary edge is dropped would prefer this snapshot /// over the position actually reported since. @protected void discardFrozenOutgoingPosition() => _frozenOutgoingPositionMs = null; /// The backend rolled into a different source on its own — a gapless /// advance. Nothing that was in flight against the old one may speak for the /// playhead any more. @protected void takeSourceOwnership() { if (_disposed) return; // Recorded before anything below overwrites it: whoever finalises the // outgoing item needs where it got to, and every position reachable from // here on belongs to the new source. _outgoingSourcePosition = Duration(milliseconds: _frozenOutgoingPositionMs ?? _lastReportedPositionMs); _frozenOutgoingPositionMs = null; _lastReportedPositionMs = 0; _takeOperationOwnership(++_playheadOperations); _activeSeekGroup = null; // A gapless advance starts the new source at its beginning, which is known // rather than guessed. Published unconditionally: the alternative is to // preserve whatever position was last reported, and at this boundary that // is far more likely to be the outgoing track's last tick than an early // one from the incoming track. If an early new-source tick really did // arrive, its successor corrects this within one tick. _lastPositionWriter = _playheadOperations; _setPlaybackPosition(Duration.zero); announcePlayheadJump(Duration.zero); } /// Lift a rejected request's optimistic target off state, if it is still the /// thing on top. /// /// What replaces it is the closest position actually known: what the backend /// last reported, else the newest target this group had accepted, else where /// it started. Nothing is announced — a rejected request's abandonment is /// announced by whoever rejected it. void _repairRejectedWrite(_SeekGroup group) { if (_disposed) return; // A later group is running: its optimistic write owns state, and even a // backend report arriving now belongs inside its window, not this one's. if (_activeSeekGroup != null && !identical(_activeSeekGroup, group)) return; if (_lastPositionWriter == _backendReportedWriter) { // The backend has reported since, so it is authoritative whatever the // value — this must be checked before anything that infers ownership from // the value itself. `PlayerState.position` is throttled and can still be // showing an abandoned target, so bring it into line. final ticked = Duration(milliseconds: _positionMs); if (_state.position != ticked) _setPlaybackPosition(ticked); return; } if (group.landedRequest == group.newestRequest) return; // Someone else wrote since; their value stands even if it happens to match // this group's target. if (_lastPositionWriter != group.newestRequest) return; _setPlaybackPosition(group.landedTarget ?? group.anchor); } bool _hasUnresolvedSeekNewerThan(int token) { // Asked of the requests still outstanding, not of the group's newest: that // one may already have settled while an older sibling holds the group open. final group = _activeSeekGroup; return group != null && group.unsettled.any((request) => request > token); } @protected Duration? get configuredTimelineDuration => _timelineDuration; /// Install a freshly opened source at [sourcePosition]. /// /// An in-place reload — dead-stream recovery, a quality/version switch, a /// background-suspend resume — places the playhead here rather than through /// [runSeek], so this is the second way it can move discontinuously. @protected void resetPlaybackProgress(Duration sourcePosition) { final position = sourcePosition; _positionMs = position.inMilliseconds; // A source is being installed at this position; nothing has been reported // about it yet, and its predecessor's position says nothing about it. _lastReportedPositionMs = position.inMilliseconds; _state = _state.copyWith( completed: false, position: position, duration: _timelineDuration ?? Duration.zero, buffer: Duration.zero, bufferRanges: const [], ); _takeOperationOwnership(++_playheadOperations); _lastPositionWriter = _playheadOperations; _activeSeekGroup = null; completedController.add(false); positionController.add(position); announcePlayheadJump(position); durationController.add(_timelineDuration ?? Duration.zero); bufferController.add(Duration.zero); bufferRangesController.add(const []); } @protected void restoreTracks(PlayerState snapshot) { _state = _state.copyWith(tracks: snapshot.tracks, track: snapshot.track); tracksController.add(snapshot.tracks); trackController.add(snapshot.track); } /// Put back the state a failed open tore down. /// /// [resetPlaybackProgress] already announced the start position the open was /// aiming for, so undoing it has to be announced too — otherwise a consumer /// that pinned the abandoned resume target keeps building on it. @protected void restorePlaybackProgress(PlayerState snapshot, {Duration? position}) { final restoredPosition = position ?? snapshot.position; _positionMs = restoredPosition.inMilliseconds; _state = _state.copyWith( completed: snapshot.completed, position: restoredPosition, duration: snapshot.duration, buffer: snapshot.buffer, bufferRanges: snapshot.bufferRanges, ); _takeOperationOwnership(++_playheadOperations); _lastPositionWriter = _playheadOperations; _activeSeekGroup = null; completedController.add(snapshot.completed); positionController.add(restoredPosition); announcePlayheadJump(restoredPosition); durationController.add(snapshot.duration); bufferController.add(snapshot.buffer); bufferRangesController.add(snapshot.bufferRanges); } @protected Future invoke(String method, [dynamic args]) async { if (_disposed) return null; if (_nativeOwnershipReady case final ready?) { try { await ready.timeout(debugNativeOwnershipDisposeTimeout); } on TimeoutException { return null; } } if (_disposed) return null; return methodChannel.invokeMethod(method, args); } @override Future playOrPause() async { if (_disposed) return; if (_state.playing) { await pause(); } else { await play(); } } @override Future setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {} @override Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async { if (_disposed) return false; try { await invoke('setVisible', {'visible': visible, 'restoreOnWindowVisible': restoreOnWindowVisible}); return true; } catch (e) { errorController.add(PlayerError('Failed to set visibility: $e')); return false; } } @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future updateFrame() async {} @override Future setVideoFrameRate( double fps, int durationMs, { int extraDelayMs = 0, int videoWidth = 0, int videoHeight = 0, }) async => false; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future clearVideoFrameRate() async {} @override // ignore: no-empty-block - base no-op, ExoPlayer styles subtitles natively Future setSubtitleStyle({ required double fontSize, required String textColor, required double borderSize, required String borderColor, required String bgColor, required int bgOpacity, int subtitlePosition = 100, bool bold = false, bool italic = false, }) async {} @override // ignore: no-empty-block - base no-op, mpv scales via panscan/aspect-override Future setBoxFitMode(int mode) async {} @override // ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property Future setVideoZoom(double scale) async {} @override Future> getStats() async => const {}; @override Future runtimePlayerType() async => playerType; @override Future requestAudioFocus() async { // Default returns true, overridden by Android return true; } @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future abandonAudioFocus() async {} @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioDevice(AudioDevice device) async {} @override bool get supportsSecondarySubtitles => true; @override bool get attachesExternalSubtitlesAtOpen => false; @override bool get detectsFpsAfterRender => false; @override bool get needsDecoderRefreshAfterDisplaySwitch => false; @override bool get providesNativeStats => false; @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future selectSecondarySubtitleTrack(SubtitleTrack track) async {} @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioPassthrough(bool enabled) async {} @override Future getAudioRenderingMode() async => null; /// mpv loudnorm targeting streaming-style loudness; mirrored by the /// Android ExoPlayer effect parameters in AudioNormalizationEffect.kt. static const _loudnormFilter = 'loudnorm=I=-14:TP=-3:LRA=4'; @override Future setAudioNormalization(bool enabled) async { await setProperty('af', enabled ? _loudnormFilter : ''); } @override Future setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async { if (enabled) { // Kodi's mechanism: center coefficient = 10^((-3 + boost)/20); the // surround (-3 dB) and LFE (dropped) swresample defaults already match. final c = math.pow(10, (-3 + centerBoostDb.clamp(0, 12)) / 20).toStringAsFixed(4); // Swresample AVOptions are read once at audio-filter creation, so they // must land before audio-channels triggers the chain (re)build. await setProperty('audio-swresample-o', 'center_mix_level=$c'); await setProperty('audio-normalize-downmix', normalize ? 'yes' : 'no'); // Bounce through auto-safe so boost/normalize changes re-apply while // downmix is already active (same-value option sets are no-ops in mpv). await setProperty('audio-channels', 'auto-safe'); await setProperty('audio-channels', 'stereo'); } else { await setProperty('audio-channels', 'auto-safe'); await setProperty('audio-swresample-o', ''); await setProperty('audio-normalize-downmix', 'no'); } } @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setLogLevel(String level) async {} @override Future configureSubtitleFonts() async { try { final fontDir = await SubtitleFontLoader.loadSubtitleFont(); if (fontDir != null) { await setProperty('sub-fonts-dir', fontDir); await setProperty('sub-font', SubtitleFontLoader.fontName); } } catch (e) { // Font configuration is not critical - continue without it logController.add( PlayerLog(prefix: 'fonts', level: PlayerLogLevel.warn, text: 'Failed to configure subtitle fonts: $e'), ); } } void _setPlaybackPosition(Duration position) { _positionMs = position.inMilliseconds; _state = _state.copyWith(position: position); positionController.add(position); } /// Run a backend-specific seek call, swallowing the common "not ready" errors /// the native channel throws when the engine was torn down mid-seek. /// /// Seeks can overlap, and each one optimistically writes its own target, so /// no single call knows where the playhead really ended up. The last of an /// overlapping group to settle owns the correction: if any of them landed the /// backend is at that target, and if none did, nothing moved and the position /// from before the group is the truth. @protected Future runSeek(Duration position, Future Function() seekFn) async { if (_disposed) return; final request = ++_playheadOperations; // Measures how much media time playback could legitimately have covered // while the command was in flight; a fixed media-time window cannot tell a // fast-rate advance from a stale pre-seek tick. final elapsedInFlight = Stopwatch()..start(); final group = _activeSeekGroup ??= _SeekGroup(Duration(milliseconds: _positionMs)); group.unsettled.add(request); group.newestRequest = request; _lastPositionWriter = request; _setPlaybackPosition(position); // Announce the request, not its completion: consumers coalescing their own // seeks need to know the playhead moved out from under them while the // backend is still working, which is exactly the window a late signal would // miss. announcePlayheadJump(position); void settle({required bool landed}) { // A newer request in this group has already displaced the playhead // optimistically, so nothing here can read the backend's position: record // the exact target and let the group reconcile once that newer request // resolves. if (landed && request != group.newestRequest) { if (request > group.landedRequest) { group.landedRequest = request; group.landedTarget = position; group.landedSince = elapsedInFlight; } if (request > _acceptedOperation) _takeOperationOwnership(request); } else if (landed) { final authoritative = _progressedFrom(position, elapsedInFlight); if (request > group.landedRequest) { // The backend applies commands in issue order, so the newest request // it accepted is where it ends up — whichever reply came back first. group.landedRequest = request; group.landedTarget = authoritative; group.landedSince = elapsedInFlight; } if (request > _acceptedOperation) { // Newest thing the backend has accepted, so the playhead is here. // Applied now rather than at group drain: the group may be detached // by a relocation or still waiting on an older member, and neither // changes the fact that nothing newer has been accepted. _takeOperationOwnership(request); if (_positionMs != authoritative.inMilliseconds || _state.position != authoritative) { _setPlaybackPosition(authoritative); } } } group.unsettled.remove(request); if (group.unsettled.isNotEmpty) return; if (!identical(_activeSeekGroup, group)) { // Detached: something outside the group took the playhead after it // started — a relocation, or a different source starting. A deferred // relocation is deliberately left alone here — it is held against // whichever group is active now, which may well be a later one than // this, and resolving it from here would publish into that group's // window or restore an anchor from a timeline it never saw. Its own // rejected write is still its to clean up, though. _repairRejectedWrite(group); return; } _activeSeekGroup = null; if (_disposed) return; if (_resolveDeferredRelocation(group)) return; final anchor = group.anchor; final landedTarget = group.landedTarget; final newestRequestFailed = group.landedRequest != group.newestRequest; if (landedTarget != null) { // Something landed and already applied itself. Only a rejected newest // request needs undoing here: its optimistic write is still on top, and // the accepted target of a newest request went out as its own request. if (newestRequestFailed) { // The rejected newest request's optimistic write is still on top. // Replace it with the accepted target, or with a tick that has since // run on from it — rewinding real progress would be its own bug. final settled = _progressedFrom(landedTarget, group.landedSince); _setPlaybackPosition(settled); announcePlayheadJump(settled); } return; } // Every seek in the group was rejected, so the playhead never went where // it was announced. Undo it in state and on the stream, or a consumer // keeps building on a position the backend refused. if (_lastPositionWriter == _backendReportedWriter) { // The backend reported a position while the group was in flight, so the // pre-group position is not what to restore — but `PlayerState.position` // is throttled and can still be showing an abandoned target, so publish // the reported value rather than leaving that on display. final ticked = Duration(milliseconds: _positionMs); _setPlaybackPosition(ticked); announcePlayheadJump(ticked); return; } _setPlaybackPosition(anchor); announcePlayheadJump(anchor); } try { await seekFn(); settle(landed: true); } on PlatformException catch (e) { settle(landed: false); if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') { appLogger.w('Seek failed (${e.code}), player not ready'); return; } rethrow; } catch (_) { settle(landed: false); rethrow; } } /// Injects the log + error events that would fire when the server rejects the /// stream with [status]. Used by the in-player debug buttons to preview the /// end-to-end detection path without needing a real misbehaving server: 500 /// is a shared-user bandwidth/transcoding limit, 404 a file the server can no /// longer read. The warn-level log mirrors ffmpeg's real wording, which is /// what [PlayerError.httpStatusFromLog] parses. void debugSimulateServerHttpError(int status) { if (_disposed) return; logController.add( PlayerLog(level: PlayerLogLevel.warn, prefix: 'ffmpeg', text: 'https: HTTP error $status Simulated'), ); final cause = switch (status) { 500 => PlayerError.serverHttp500, 404 => PlayerError.serverHttp404, _ => null, }; errorController.add(PlayerError('HTTP $status', cause: cause)); } Future _waitForNativeOwnershipForDispose() async { final ready = _nativeOwnershipReady; if (ready == null) return true; try { await ready.timeout(debugNativeOwnershipDisposeTimeout); return true; } on TimeoutException catch (error, stackTrace) { appLogger.w( 'Timed out waiting for the previous player to release the native channel; skipping native dispose', error: error, stackTrace: stackTrace, ); if (!_nativeRelease.isCompleted) _nativeRelease.complete(ready); return false; } } @override Future dispose({bool preserveDisplayMode = false}) async { if (_disposed) return; _disposed = true; _textureId.value = null; final channelName = eventChannel.name; if (identical(_eventChannelOwners[channelName], this)) { // Keep this owner registered while its native release is pending so a // player created during disposal inherits the complete release chain. // The newer listen cannot interleave before cancel() is invoked on this // isolate; after the first await, ownership is checked again at removal. try { await _eventSubscription?.cancel(); } on PlatformException catch (e, st) { appLogger.d('Player event stream already detached during dispose', error: e, stackTrace: st); } on MissingPluginException catch (e, st) { appLogger.d('Player event stream plugin missing during dispose', error: e, stackTrace: st); } } else { // A newer instance owns the channel; cancelling would send a stray // native 'cancel' that kills *its* stream. Drop ours without cancelling // — the newer listen already replaced this subscription's routing. appLogger.d('Player event stream handed off to a newer instance, skipping cancel'); } _eventSubscription = null; await _logSubscription?.cancel(); final ownsNativeChannel = await _waitForNativeOwnershipForDispose(); try { if (ownsNativeChannel) { await methodChannel.invokeMethod('dispose', { 'preserveDisplayMode': preserveDisplayMode, }); // Direct call — invoke() is disabled once _disposed is set. } } on PlatformException catch (e, st) { appLogger.w('Player native dispose failed during teardown', error: e, stackTrace: st); } on MissingPluginException catch (e, st) { appLogger.w('Player native dispose plugin missing during teardown', error: e, stackTrace: st); } finally { if (ownsNativeChannel && !_nativeRelease.isCompleted) _nativeRelease.complete(); } // A timed-out predecessor is still represented by this release future. // Do not expose an empty ownership slot until that chained release settles. if (_nativeRelease.isCompleted) { unawaited( _nativeRelease.future.whenComplete(() { if (identical(_eventChannelOwners[channelName], this)) { _eventChannelOwners.remove(channelName); } }), ); } await closeStreamControllers(); _textureId.dispose(); } } /// One run of overlapping [PlayerBase.runSeek] calls. /// /// Each seek writes its own target optimistically, so no single call knows /// where the backend actually ended up; the last one to settle reconciles the /// group. [anchor] is where the playhead was before the first of them started. class _SeekGroup { _SeekGroup(this.anchor); final Duration anchor; /// The source this group was issued against. final Set unsettled = {}; int newestRequest = 0; int landedRequest = 0; Duration? landedTarget; /// The winning request's own flight clock, still running. A drain uses it to /// tell playback progressing from [landedTarget] apart from a stale /// observation; restarting it at settlement would undercount media time the /// backend covered while a slow command was still being answered. Stopwatch? landedSince; }