Files
plezy/lib/mpv/player/player_base.dart
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

1021 lines
35 KiB
Dart

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<void> setNext(Media? media) async {}
late final PlayerStreams _streams;
@override
PlayerStreams get streams => _streams;
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
@override
int? get textureId => _textureId.value;
ValueListenable<int?> get textureIdListenable => _textureId;
@protected
void setTextureId(int? value) {
if (!_disposed) _textureId.value = value;
}
StreamSubscription? _eventSubscription;
StreamSubscription? _logSubscription;
bool _disposed = false;
late final Future<void>? _nativeOwnershipReady;
final Completer<void> _nativeRelease = Completer<void>();
final _throttleSw = Stopwatch()..start();
int _lastEmitMs = 0;
int _lastCacheStateMs = 0;
int _positionMs = 0;
Duration? _timelineDuration;
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
Map<String, List<SubtitleTrack>> _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<String, PlayerBase> _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<void> observeCoreProperties({required String trackListFormat}) async {
for (final (name, format) in corePropertyObservations) {
await observeProperty(name, format);
}
await observeProperty('track-list', trackListFormat);
}
@protected
Future<void> 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;
// 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 = <AudioDevice>[];
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 = <BufferRange>[];
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 = <AudioTrack>[];
final subtitleTracks = <SubtitleTrack>[];
String? selectedAudioId;
String? selectedSubtitleId;
final containerMetadataIndexes = <String, int>{};
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<SubtitleTrack>? externalSubtitles) {
final metadataByUri = <String, List<SubtitleTrack>>{};
for (final subtitle in externalSubtitles ?? const <SubtitleTrack>[]) {
final uri = subtitle.uri;
if (uri != null && uri.isNotEmpty) {
(metadataByUri[uri] ??= <SubtitleTrack>[]).add(subtitle);
}
}
_externalSubtitleMetadataByUri = metadataByUri;
}
@protected
Map<String, List<SubtitleTrack>> snapshotExternalSubtitleMetadata() =>
Map<String, List<SubtitleTrack>>.of(_externalSubtitleMetadataByUri);
@protected
void restoreExternalSubtitleMetadata(Map<String, List<SubtitleTrack>> snapshot) {
_externalSubtitleMetadataByUri = Map<String, List<SubtitleTrack>>.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;
}
@protected
Duration? get configuredTimelineDuration => _timelineDuration;
@protected
void resetPlaybackProgress(Duration sourcePosition) {
final position = sourcePosition;
_positionMs = position.inMilliseconds;
_state = _state.copyWith(
completed: false,
position: position,
duration: _timelineDuration ?? Duration.zero,
buffer: Duration.zero,
bufferRanges: const [],
);
completedController.add(false);
positionController.add(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);
}
@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,
);
completedController.add(snapshot.completed);
positionController.add(restoredPosition);
durationController.add(snapshot.duration);
bufferController.add(snapshot.buffer);
bufferRangesController.add(snapshot.bufferRanges);
}
@protected
Future<T?> invoke<T>(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<T>(method, args);
}
@override
Future<void> playOrPause() async {
if (_disposed) return;
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<bool> 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<void> updateFrame() async {}
@override
Future<bool> 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<void> clearVideoFrameRate() async {}
@override
// ignore: no-empty-block - base no-op, ExoPlayer styles subtitles natively
Future<void> 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<void> setBoxFitMode(int mode) async {}
@override
// ignore: no-empty-block - base no-op, mpv zooms via the video-zoom property
Future<void> setVideoZoom(double scale) async {}
@override
Future<Map<String, dynamic>> getStats() async => const {};
@override
Future<String> runtimePlayerType() async => playerType;
@override
Future<bool> requestAudioFocus() async {
// Default returns true, overridden by Android
return true;
}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> abandonAudioFocus() async {}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> 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<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@override
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> setAudioPassthrough(bool enabled) async {}
@override
Future<AudioRenderingMode?> 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<void> setAudioNormalization(bool enabled) async {
await setProperty('af', enabled ? _loudnormFilter : '');
}
@override
Future<void> 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<void> setLogLevel(String level) async {}
@override
Future<void> 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.
@protected
Future<void> runSeek(Duration position, Future<void> Function() seekFn) async {
if (_disposed) return;
final previousPosition = Duration(milliseconds: _positionMs);
_setPlaybackPosition(position);
void rollbackPosition() {
// Avoid overwriting a newer native position update if one arrived while
// the platform seek was in flight.
if (_positionMs == position.inMilliseconds) {
_setPlaybackPosition(previousPosition);
}
}
try {
await seekFn();
} on PlatformException catch (e) {
if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
rollbackPosition();
appLogger.w('Seek failed (${e.code}), player not ready');
return;
}
rollbackPosition();
rethrow;
} catch (_) {
rollbackPosition();
rethrow;
}
}
/// Injects the log + error events that would fire when the server rejects the
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
/// the in-player debug button to preview the end-to-end detection path
/// without needing a real misbehaving server.
void debugSimulateServer500() {
if (_disposed) return;
logController.add(
const PlayerLog(
level: PlayerLogLevel.warn,
prefix: 'ffmpeg',
text: 'https: HTTP error 500 Internal Server Error',
),
);
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
}
Future<bool> _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<void> 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();
}
}