@@ -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});
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user