refactor(player): centralize playback opening

close #1280
This commit is contained in:
edde746
2026-06-10 02:26:26 +02:00
parent 18642cae15
commit cd9498abb8
51 changed files with 3016 additions and 1422 deletions
+32 -12
View File
@@ -13,6 +13,11 @@ class PlayerAndroid extends PlayerBase {
bool _tunnelingEnabled = true;
String _dvConversionMode = 'auto';
/// The native plugin switched from ExoPlayer to its mpv fallback for this
/// session. Sticky for the instance lifetime, mirroring the native flag
/// (which resets only on initialize/dispose).
bool _usingMpvFallback = false;
String? _hiddenSubtitleTrackId;
@override
@@ -30,12 +35,32 @@ class PlayerAndroid extends PlayerBase {
@override
bool get supportsSecondarySubtitles => false;
// Under the mpv fallback the native open path drops the externalSubtitles
// argument, so subsequent opens must use the post-open sub-add dance
// (handleAddSubtitleTrack routes to mpv natively).
@override
bool get attachesExternalSubtitlesAtOpen => !_usingMpvFallback;
// The fallback runs mpv over MediaCodec — the same display-switch decoder
// constraint as PlayerNative on Android. The whole startup-gate chain
// (setVideoFrameRate, playback-restart, seek/drop-buffers refresh,
// open-paused) already routes per-core natively.
@override
bool get needsDecoderRefreshAfterDisplaySwitch => _usingMpvFallback;
@override
bool get detectsFpsAfterRender => true;
@override
bool get providesNativeStats => true;
@override
void handlePlayerEvent(String name, Map? data) {
if (name == 'backend-switched') {
// Native player switched from ExoPlayer to MPV due to unsupported format.
// Clear stale ExoPlayer tracks so applyTrackSelectionWhenReady waits for
// mpv's track-list instead of immediately applying with ExoPlayer IDs.
_usingMpvFallback = true;
clearTracks();
backendSwitchedController.add(null);
return;
@@ -70,17 +95,7 @@ class PlayerAndroid extends PlayerBase {
// Register property observers before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
// future would falsely treat as ready.
await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', 'string');
await observeProperty('eof-reached', 'flag');
await observeProperty('volume', 'double');
await observeProperty('speed', 'double');
await observeProperty('aid', 'string');
await observeProperty('sid', 'string');
await observeCoreProperties(trackListFormat: 'string');
await observeProperty('demuxer-cache-time', 'double');
initialized = true;
@@ -283,6 +298,7 @@ class PlayerAndroid extends PlayerBase {
}
}
@override
Future<Map<String, dynamic>> getStats() async {
if (disposed) return {};
try {
@@ -303,7 +319,8 @@ class PlayerAndroid extends PlayerBase {
}
}
Future<String> getPlayerType() async {
@override
Future<String> runtimePlayerType() async {
if (disposed) return 'unknown';
try {
final result = await invoke<String>('getPlayerType');
@@ -352,6 +369,7 @@ class PlayerAndroid extends PlayerBase {
///
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
/// For ASS subtitles, applies font scale via libass setFontScale().
@override
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
@@ -379,12 +397,14 @@ class PlayerAndroid extends PlayerBase {
/// Apply the box-fit mode to the native ExoPlayer layer.
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
@override
Future<void> setBoxFitMode(int mode) async {
if (disposed || !initialized) return;
await invoke('setBoxFitMode', {'mode': mode});
}
/// Apply custom zoom to the native ExoPlayer layer.
@override
Future<void> setVideoZoom(double scale) async {
if (disposed || !initialized) return;
await invoke('setVideoZoom', {'scale': scale});
+70 -2
View File
@@ -99,6 +99,28 @@ abstract class Player {
/// Whether this player backend supports secondary subtitle tracks.
bool get supportsSecondarySubtitles;
/// Whether this backend ingests external subtitles in [open] (single
/// prepare(), safe to auto-play immediately). Backends returning false
/// need external subtitles added after open via [addSubtitleTrack] while
/// paused, and the caller resumes once the tracks are selected.
bool get attachesExternalSubtitlesAtOpen;
/// Whether the backend detects container fps from rendered frame
/// timestamps, so `container-fps` only becomes available a few frames
/// after playback starts (retry the property read instead of giving up).
bool get detectsFpsAfterRender;
/// Whether the video decoder must be refreshed (seek-in-place or
/// drop-buffers) after a display mode switch. True for mpv on Android,
/// where MediaCodec can stall against the reconfigured surface.
bool get needsDecoderRefreshAfterDisplaySwitch;
/// Whether [getStats] aggregates performance stats natively for the
/// active backend (the Android plugin covers both ExoPlayer and its mpv
/// fallback). Backends returning false are sampled via mpv property
/// reads instead.
bool get providesNativeStats;
/// Add an external subtitle track.
///
/// [uri] - URL or path to the subtitle file.
@@ -156,7 +178,10 @@ abstract class Player {
/// Prime native display matching from server metadata before the decoder
/// emits stream properties. Unsupported platforms ignore this.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria);
///
/// [extraDelayMs] is added after a native display-switch completion event,
/// for TVs or AVRs that need extra HDMI settle time.
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0});
/// Configure subtitle fonts for libass rendering.
///
@@ -215,6 +240,46 @@ abstract class Player {
/// On other platforms, this is a no-op.
Future<void> clearVideoFrameRate();
/// Apply subtitle styling to the native rendering layer.
///
/// ExoPlayer renders subtitles natively (CaptionStyleCompat for text subs,
/// libass font scale for ASS), so styling must be pushed after [open].
/// No-op on mpv backends, which style subtitles via `sub-*` properties.
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,
});
/// Apply the box-fit mode to the native video layer
/// (0=FIT, 1=ZOOM/cover, 2=FILL/stretch).
///
/// ExoPlayer scales via AspectRatioFrameLayout; mpv backends are a no-op
/// here and scale via `panscan`/`video-aspect-override` properties instead.
Future<void> setBoxFitMode(int mode);
/// Apply custom zoom to the native video layer. No-op on mpv backends,
/// which zoom via the `video-zoom` property.
Future<void> setVideoZoom(double scale);
/// Aggregated native playback stats (codecs, dimensions, dropped frames…).
///
/// Returns an empty map on backends without native stats aggregation;
/// query mpv properties directly there instead.
Future<Map<String, dynamic>> getStats();
/// The backend actually playing right now, resolved from the native side.
///
/// Unlike [playerType] (the configured backend), this reflects runtime
/// fallbacks — e.g. 'mpv' after ExoPlayer hit an unsupported format.
Future<String> runtimePlayerType();
/// Request audio focus before starting playback.
///
/// On Android, this notifies the system that the app wants to play audio,
@@ -237,8 +302,11 @@ abstract class Player {
/// Dispose of the player and release resources.
///
/// [preserveDisplayMode] keeps any native display-mode hint active while a
/// replacement video route is being opened. Use false when leaving playback.
///
/// After calling this, the player instance should not be used.
Future<void> dispose();
Future<void> dispose({bool preserveDisplayMode = false});
/// Creates a new player instance.
///
+74 -3
View File
@@ -98,6 +98,35 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
/// 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++;
@@ -543,7 +572,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {}
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
@@ -568,6 +597,34 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
// 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
@@ -585,6 +642,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@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 {}
@@ -669,13 +738,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
}
@override
Future<void> dispose() async {
Future<void> dispose({bool preserveDisplayMode = false}) async {
if (_disposed) return;
_disposed = true;
await _eventSubscription?.cancel();
await _logSubscription?.cancel();
await methodChannel.invokeMethod('dispose'); // Direct call — already guarded by _disposed check above
await methodChannel.invokeMethod('dispose', {
'preserveDisplayMode': preserveDisplayMode,
}); // Direct call — already guarded by _disposed check above
await closeStreamControllers();
}
}
+17 -13
View File
@@ -112,17 +112,7 @@ class PlayerNative extends PlayerBase {
// Subscribe to MPV properties before flipping `initialized` so partial
// failures don't leave us in a half-initialized state that the memoized
// future would falsely treat as ready.
await observeProperty('time-pos', 'double');
await observeProperty('duration', 'double');
await observeProperty('seekable', 'flag');
await observeProperty('pause', 'flag');
await observeProperty('paused-for-cache', 'flag');
await observeProperty('track-list', _nodeFormat);
await observeProperty('eof-reached', 'flag');
await observeProperty('volume', 'double');
await observeProperty('speed', 'double');
await observeProperty('aid', 'string');
await observeProperty('sid', 'string');
await observeCoreProperties(trackListFormat: _nodeFormat);
await observeProperty('secondary-sid', 'string');
await observeProperty('demuxer-cache-state', _nodeFormat);
await observeProperty('audio-device-list', _nodeFormat);
@@ -195,6 +185,14 @@ class PlayerNative extends PlayerBase {
}
await command(['loadfile', uri, 'replace']);
// mpv's pause property survives loadfile; in-place reloads pause the old
// file before resolving, so explicitly unpause for the replacement. Set
// after loadfile so the paused old file never audibly unpauses
// pre-replace.
if (play) {
await setProperty('pause', 'no');
}
}
@override
@@ -295,10 +293,16 @@ class PlayerNative extends PlayerBase {
}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {
bool get needsDecoderRefreshAfterDisplaySwitch => Platform.isAndroid;
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (disposed || !Platform.isIOS) return;
await _ensureInitialized();
await invoke('setDisplayCriteria', {'criteria': _effectiveDisplayCriteria(criteria)?.toJson()});
await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
'extraDelayMs': extraDelayMs,
});
}
@override
-12
View File
@@ -14,9 +14,6 @@ class PlayerState {
final double rate;
final Tracks tracks;
final TrackSelection track;
final double audioDelay;
final double subtitleDelay;
final bool audioPassthrough;
final AudioDevice audioDevice;
final List<AudioDevice> audioDevices;
final List<BufferRange> bufferRanges;
@@ -33,9 +30,6 @@ class PlayerState {
this.rate = 1.0,
this.tracks = const Tracks(),
this.track = const TrackSelection(),
this.audioDelay = 0.0,
this.subtitleDelay = 0.0,
this.audioPassthrough = false,
this.audioDevice = AudioDevice.auto,
this.audioDevices = const [],
this.bufferRanges = const [],
@@ -53,9 +47,6 @@ class PlayerState {
double? rate,
Tracks? tracks,
TrackSelection? track,
double? audioDelay,
double? subtitleDelay,
bool? audioPassthrough,
AudioDevice? audioDevice,
List<AudioDevice>? audioDevices,
List<BufferRange>? bufferRanges,
@@ -72,9 +63,6 @@ class PlayerState {
rate: rate ?? this.rate,
tracks: tracks ?? this.tracks,
track: track ?? this.track,
audioDelay: audioDelay ?? this.audioDelay,
subtitleDelay: subtitleDelay ?? this.subtitleDelay,
audioPassthrough: audioPassthrough ?? this.audioPassthrough,
audioDevice: audioDevice ?? this.audioDevice,
audioDevices: audioDevices ?? this.audioDevices,
bufferRanges: bufferRanges ?? this.bufferRanges,