ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or less free, with no way to raise it. On hardware where mpv cannot render at all that ceiling is the whole buffer budget. Playback Buffer offers Auto, Large and Extra Large. The durations are taken from jellyfin-androidtv and jellyfin-android so the same words mean the same thing across Jellyfin clients; Auto keeps the memory-tiered values that shipped. Named tiers rather than a duration because a duration would be a promise the load control cannot keep: prioritizeTimeOverSizeThresholds is disabled, so targetBufferBytes stops the loader even below minBufferMs and the byte cap binds first above roughly 23 Mbit/s. The tier crosses the method channel as a string and resolves in the core, where an unrecognised name falls back to Auto. LoadControlPolicy clamps the resulting pair: media3 validates the ordering with Guava Preconditions, an unconditional throw R8 does not elide, so a bad pair would be an IllegalArgumentException out of player construction rather than a bad buffer. The two play-start thresholds stay fixed even though the Jellyfin tiers move them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the stall watchdog indict a player that is obeying its own load control. That leaves the byte target as a second, often smaller ceiling, and nothing surfaced either. The resolved values now reach getStats, and the overlay's Buffer section gains a Cache Limit row reading "120s / 128MB" next to the buffered-ahead duration, so a tier that appears to do nothing on a high-bitrate file explains itself. close #1816
625 lines
21 KiB
Dart
625 lines
21 KiB
Dart
import 'package:collection/collection.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../../services/device_performance.dart';
|
|
import '../../../services/settings_service.dart';
|
|
import '../../models.dart';
|
|
import '../player_base.dart';
|
|
|
|
/// Android implementation using ExoPlayer with ASS subtitle support via libass-android.
|
|
class PlayerAndroid extends PlayerBase {
|
|
static const _methodChannel = MethodChannel('com.plezy/exo_player');
|
|
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
|
|
|
|
int? _bufferSizeBytes;
|
|
bool _bufferSizeIsAuto = false;
|
|
String _bufferTier = 'auto';
|
|
bool _tunnelingEnabled = true;
|
|
String _dvConversionMode = 'auto';
|
|
bool _audioNormalizationEnabled = false;
|
|
bool _audioPassthroughEnabled = false;
|
|
bool _downmixEnabled = false;
|
|
int _downmixCenterBoostDb = 0;
|
|
bool _downmixNormalize = true;
|
|
|
|
/// Server-reported frame rate for the next item, or null when unknown.
|
|
///
|
|
/// Rides on `open` rather than a standalone call because it is per-item and
|
|
/// must be known before the native side settles tunneling for that item.
|
|
double? _contentFrameRate;
|
|
|
|
/// 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;
|
|
|
|
bool get usingMpvFallback => _usingMpvFallback;
|
|
|
|
/// Subtitles are hidden through the player's visibility toggle. Sticky
|
|
/// across media opens, like mpv's global `sub-visibility`, so an episode
|
|
/// change cannot put them back on screen.
|
|
bool _subtitlesHidden = false;
|
|
|
|
/// Track that un-hiding restores: whatever was selected when subtitles were
|
|
/// hidden, then whatever was selected for the current media while hidden.
|
|
String? _hiddenSubtitleTrackId;
|
|
|
|
@override
|
|
MethodChannel get methodChannel => _methodChannel;
|
|
|
|
@override
|
|
EventChannel get eventChannel => _eventChannel;
|
|
|
|
@override
|
|
String get logPrefix => 'ExoPlayer';
|
|
|
|
@override
|
|
String get playerType => 'exoplayer';
|
|
|
|
@override
|
|
bool get supportsSecondarySubtitles => false;
|
|
|
|
// ExoPlayer attaches external subtitles to the MediaItem before prepare;
|
|
// the Android mpv fallback mirrors PlayerNative by passing sub-files through
|
|
// loadfile options.
|
|
@override
|
|
bool get attachesExternalSubtitlesAtOpen => true;
|
|
|
|
// 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
|
|
bool get audioPassthroughActive => _audioPassthroughEnabled;
|
|
|
|
@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.
|
|
final wasUsingMpvFallback = _usingMpvFallback;
|
|
_usingMpvFallback = true;
|
|
if (!wasUsingMpvFallback) {
|
|
clearTracks();
|
|
}
|
|
backendSwitchedController.add(null);
|
|
return;
|
|
}
|
|
|
|
super.handlePlayerEvent(name, data);
|
|
}
|
|
|
|
// Memoizes the in-flight init Future so concurrent callers share one
|
|
// `invoke('initialize')`. ExoPlayer's native handleInitialize is
|
|
// synchronous and would mask a Dart-side race anyway, but we mirror the
|
|
// pattern from PlayerNative for consistency and to avoid a partial-init
|
|
// hole if any observeProperty call throws.
|
|
Future<void>? _initFuture;
|
|
|
|
Future<void> _ensureInitialized() async {
|
|
if (initialized) return;
|
|
return _initFuture ??= _doInitialize();
|
|
}
|
|
|
|
Future<void> _doInitialize() async {
|
|
try {
|
|
final result = await invoke<bool>('initialize', {
|
|
'bufferSizeBytes': _bufferSizeBytes,
|
|
'bufferSizeAuto': _bufferSizeIsAuto,
|
|
'bufferTier': _bufferTier,
|
|
'tunnelingEnabled': _tunnelingEnabled,
|
|
'dvConversionMode': _dvConversionMode,
|
|
'audioPassthroughEnabled': _audioPassthroughEnabled,
|
|
// Cheap (32-bit) TV boxes run the hardware video path a frame behind a GL
|
|
// subtitle overlay; render the ASS one frame earlier there to realign.
|
|
'assVideoLatencyFrames': DevicePerformance.isLowEndHardware ? 1 : 0,
|
|
// libass overlay raster scale from the "Render Resolution" subtitle setting
|
|
// (Full / ¾ / ½ / ⅓ / ¼); < 1 trades sharpness for throughput on slow GPUs.
|
|
'subtitleRenderScale': SettingsService.instance
|
|
.read(SettingsService.subtitleRenderResolution)
|
|
.androidRenderScale,
|
|
});
|
|
if (disposed) throw StateError('Player was disposed during initialization');
|
|
if (result != true) {
|
|
throw Exception('Failed to initialize ExoPlayer');
|
|
}
|
|
|
|
// 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 observeCoreProperties(trackListFormat: 'string');
|
|
await observeProperty('demuxer-cache-time', 'double');
|
|
if (disposed) throw StateError('Player was disposed during initialization');
|
|
|
|
// These settings can be queued before any operation initializes the
|
|
// native core. Apply the latest requested values now so ExoPlayer and
|
|
// the already-queued mpv fallback properties start in the same state.
|
|
await invoke('setAudioNormalization', {'enabled': _audioNormalizationEnabled});
|
|
await invoke('setAudioDownmix', {
|
|
'enabled': _downmixEnabled,
|
|
'centerBoostDb': _downmixCenterBoostDb,
|
|
'normalize': _downmixNormalize,
|
|
});
|
|
if (disposed) throw StateError('Player was disposed during initialization');
|
|
|
|
initialized = true;
|
|
} catch (e) {
|
|
_initFuture = null;
|
|
if (!disposed) errorController.add(PlayerError('Initialization failed: $e'));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
// A setting requested before the core is up is applied by _doInitialize from
|
|
// the stored fields; one requested while an init is in flight has to be
|
|
// replayed afterwards, but only if no newer request superseded it.
|
|
Future<void> _applyWhenInitialized(Future<void> Function() apply, bool Function() stillRequested) async {
|
|
final initFuture = _initFuture;
|
|
if (initialized) {
|
|
await apply();
|
|
} else if (initFuture != null) {
|
|
await initFuture;
|
|
if (!disposed && initialized && stillRequested()) {
|
|
await apply();
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> open(
|
|
Media media, {
|
|
bool play = true,
|
|
bool isLive = false,
|
|
List<SubtitleTrack>? externalSubtitles,
|
|
Duration? timelineDuration,
|
|
}) async {
|
|
if (disposed) return;
|
|
await _ensureInitialized();
|
|
final startPosition = media.start ?? Duration.zero;
|
|
final hasStartPosition = media.start != null && startPosition > Duration.zero;
|
|
final previousState = state;
|
|
final previousPosition = currentPosition;
|
|
final previousTimelineDuration = configuredTimelineDuration;
|
|
final previousExternalSubtitleMetadata = snapshotExternalSubtitleMetadata();
|
|
configureTimeline(duration: timelineDuration);
|
|
clearTracks();
|
|
setExternalSubtitleMetadata(externalSubtitles);
|
|
resetPlaybackProgress(startPosition);
|
|
setSeekable(false);
|
|
|
|
try {
|
|
// Show the video layer
|
|
await setVisible(true);
|
|
|
|
await invoke('open', {
|
|
'uri': media.uri,
|
|
'headers': media.headers,
|
|
'startPositionMs': startPosition.inMilliseconds,
|
|
'hasStartPosition': hasStartPosition,
|
|
'autoPlay': play,
|
|
'isLive': isLive,
|
|
if (_contentFrameRate != null) 'contentFrameRate': _contentFrameRate,
|
|
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
|
|
'externalSubtitles': externalSubtitles
|
|
.where((s) => s.uri?.isNotEmpty == true)
|
|
.map(
|
|
(s) => {
|
|
'uri': s.uri,
|
|
'title': s.title,
|
|
'language': s.language,
|
|
'codec': s.codec,
|
|
'isDefault': s.isDefault,
|
|
'isForced': s.isForced,
|
|
'isContainer': s.isContainer,
|
|
},
|
|
)
|
|
.toList(),
|
|
});
|
|
} catch (_) {
|
|
if (!disposed) {
|
|
configureTimeline(duration: previousTimelineDuration);
|
|
restorePlaybackProgress(previousState, position: previousPosition);
|
|
restoreTracks(previousState);
|
|
restoreExternalSubtitleMetadata(previousExternalSubtitleMetadata);
|
|
setSeekable(previousState.seekable);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> play() async {
|
|
await invoke('play');
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() async {
|
|
await invoke('pause');
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
await invoke('stop');
|
|
setSeekable(false);
|
|
await setVisible(false);
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) async {
|
|
await runSeek(position, () => invoke('seek', {'positionMs': position.inMilliseconds}));
|
|
}
|
|
|
|
@override
|
|
Future<void> selectAudioTrack(AudioTrack track) async {
|
|
await invoke('selectAudioTrack', {'trackId': track.id});
|
|
}
|
|
|
|
/// ExoPlayer has no renderer-level subtitle visibility switch, so hiding is
|
|
/// implemented as deselection (see [setProperty]'s `sub-visibility` case).
|
|
/// A selection arriving while subtitles are hidden — the automatic pass
|
|
/// after an episode change, or a manual pick — becomes what un-hiding
|
|
/// restores instead of putting subtitles back on screen, matching how mpv's
|
|
/// global `sub-visibility` keeps hiding across files (#1779).
|
|
@override
|
|
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
|
if (_subtitlesHidden) {
|
|
_hiddenSubtitleTrackId = track.id == SubtitleTrack.off.id ? null : track.id;
|
|
return _selectSubtitleTrackNatively(SubtitleTrack.off);
|
|
}
|
|
return _selectSubtitleTrackNatively(track);
|
|
}
|
|
|
|
Future<void> _selectSubtitleTrackNatively(SubtitleTrack track) async {
|
|
await invoke('selectSubtitleTrack', {'trackId': track.id});
|
|
}
|
|
|
|
/// A sidecar flagged default must not draw itself onto a hidden renderer
|
|
/// either; the selection pass that follows the add records it the same way
|
|
/// [selectSubtitleTrack] does.
|
|
@override
|
|
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
|
await invoke('addSubtitleTrack', {
|
|
'uri': uri,
|
|
'title': title,
|
|
'language': language,
|
|
'select': select && !_subtitlesHidden,
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> setVolume(double volume) async {
|
|
await invoke('setVolume', {'volume': volume});
|
|
if (!disposed) setVolumeState(volume);
|
|
}
|
|
|
|
@override
|
|
Future<void> setRate(double rate) async {
|
|
await invoke('setRate', {'rate': rate});
|
|
}
|
|
|
|
@override
|
|
Future<void> setProperty(String name, String value) async {
|
|
if (disposed) return;
|
|
switch (name) {
|
|
case 'pause':
|
|
if (value == 'yes') {
|
|
await pause();
|
|
} else {
|
|
await play();
|
|
}
|
|
break;
|
|
case 'volume':
|
|
await setVolume(double.tryParse(value) ?? 100);
|
|
break;
|
|
case 'speed':
|
|
await setRate(double.tryParse(value) ?? 1.0);
|
|
break;
|
|
case 'demuxer-max-bytes':
|
|
_bufferSizeBytes = int.tryParse(value);
|
|
break;
|
|
// Not an mpv property. The heap tiers Dart derives for mpv's demuxer are the wrong
|
|
// shape for ExoPlayer's sample allocator, so on Auto the native side sizes its own
|
|
// LoadControl target instead of reusing `demuxer-max-bytes` (#1618).
|
|
case 'demuxer-max-bytes-auto':
|
|
_bufferSizeIsAuto = value != 'no';
|
|
break;
|
|
// Not an mpv property. mpv read-ahead is owned by the mpv.conf editor; this tier is
|
|
// a named ExoPlayer read-ahead depth rather than a duration because the byte cap can
|
|
// bind first (#1816).
|
|
case 'exo-buffer-tier':
|
|
_bufferTier = value;
|
|
break;
|
|
case 'tunneled-playback':
|
|
_tunnelingEnabled = value != 'no';
|
|
break;
|
|
case 'content-frame-rate':
|
|
final fps = double.tryParse(value);
|
|
_contentFrameRate = fps != null && fps > 0 ? fps : null;
|
|
break;
|
|
case 'dv-conversion-mode':
|
|
_dvConversionMode = value;
|
|
await _applyWhenInitialized(
|
|
() => invoke('setDvConversionMode', {'mode': value}),
|
|
() => _dvConversionMode == value,
|
|
);
|
|
break;
|
|
case 'sub-visibility':
|
|
if (value == 'no') {
|
|
if (_subtitlesHidden) break;
|
|
_subtitlesHidden = true;
|
|
final current = state.track.subtitle;
|
|
_hiddenSubtitleTrackId = current != null && current.id != SubtitleTrack.off.id ? current.id : null;
|
|
if (_hiddenSubtitleTrackId != null) {
|
|
await _selectSubtitleTrackNatively(SubtitleTrack.off);
|
|
}
|
|
} else {
|
|
if (!_subtitlesHidden) break;
|
|
_subtitlesHidden = false;
|
|
final storedId = _hiddenSubtitleTrackId;
|
|
_hiddenSubtitleTrackId = null;
|
|
final track = storedId == null ? null : state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
|
|
if (track != null) {
|
|
await _selectSubtitleTrackNatively(track);
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
await invoke('setMpvProperty', {'name': name, 'value': value});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> setAudioNormalization(bool enabled) async {
|
|
if (disposed) return;
|
|
_audioNormalizationEnabled = enabled;
|
|
await _applyWhenInitialized(
|
|
() => invoke('setAudioNormalization', {'enabled': enabled}),
|
|
() => _audioNormalizationEnabled == enabled,
|
|
);
|
|
// Keep the mpv af property flowing through setMpvProperty so the plugin's
|
|
// pendingMpvProperties replay applies loudnorm if exo falls back to mpv.
|
|
await super.setAudioNormalization(enabled);
|
|
}
|
|
|
|
@override
|
|
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {
|
|
if (disposed) return;
|
|
_downmixEnabled = enabled;
|
|
_downmixCenterBoostDb = centerBoostDb;
|
|
_downmixNormalize = normalize;
|
|
await _applyWhenInitialized(
|
|
() => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}),
|
|
() => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize,
|
|
);
|
|
// Keep the mpv properties flowing through setMpvProperty so the plugin's
|
|
// pendingMpvProperties replay applies downmix if exo falls back to mpv.
|
|
await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize);
|
|
}
|
|
|
|
@override
|
|
Future<void> setAudioPassthrough(bool enabled) async {
|
|
if (disposed) return;
|
|
_audioPassthroughEnabled = enabled;
|
|
await _applyWhenInitialized(
|
|
() => invoke('setAudioPassthrough', {'enabled': enabled}),
|
|
() => _audioPassthroughEnabled == enabled,
|
|
);
|
|
// Deliberately no 'audio-spdif' write: unlike normalization and downmix, the
|
|
// mpv value is not this list. mpv force-passthroughs every codec named there
|
|
// with no decode fallback, so the plugin derives it from the audio route when
|
|
// the fallback core starts. Queuing the raw list here would overwrite it and
|
|
// strand TrueHD/DTS-HD on sinks that cannot bitstream them (#1703).
|
|
}
|
|
|
|
@override
|
|
Future<String?> getProperty(String name) async {
|
|
if (disposed) return null;
|
|
switch (name) {
|
|
case 'pause':
|
|
return state.playing ? 'no' : 'yes';
|
|
case 'volume':
|
|
return state.volume.toString();
|
|
case 'speed':
|
|
return state.rate.toString();
|
|
case 'time-pos':
|
|
return (state.position.inMilliseconds / 1000.0).toString();
|
|
case 'duration':
|
|
return (state.duration.inMilliseconds / 1000.0).toString();
|
|
case 'seekable':
|
|
return state.seekable ? 'yes' : 'no';
|
|
case 'dv-conversion-mode':
|
|
final stats = await getStats();
|
|
final mode = stats['dvConversionDebugMode'];
|
|
return mode?.toString().toLowerCase();
|
|
case 'container-fps':
|
|
final fpsStats = await getStats();
|
|
final fps = fpsStats['videoFps'];
|
|
return fps?.toString();
|
|
case 'width':
|
|
case 'dwidth':
|
|
final stats = await getStats();
|
|
final width = stats['videoWidth'];
|
|
return width?.toString();
|
|
case 'height':
|
|
case 'dheight':
|
|
final stats = await getStats();
|
|
final height = stats['videoHeight'];
|
|
return height?.toString();
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> getStats() async {
|
|
if (disposed) return {};
|
|
try {
|
|
final result = await invoke<Map>('getStats');
|
|
return Map<String, dynamic>.from(result ?? {});
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Returns the device's large heap size in MB, or 0 if unavailable (Android only).
|
|
static Future<int> getHeapSize() async {
|
|
try {
|
|
final result = await _methodChannel.invokeMethod<int>('getHeapSize');
|
|
return result ?? 0;
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<String> runtimePlayerType() async {
|
|
if (disposed) return 'unknown';
|
|
try {
|
|
final result = await invoke<String>('getPlayerType');
|
|
return result ?? 'unknown';
|
|
} catch (e) {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> command(List<String> args) async {
|
|
if (disposed) return;
|
|
if (args.isEmpty) return;
|
|
|
|
switch (args.first) {
|
|
case 'loadfile':
|
|
if (args.length > 1) {
|
|
await open(Media(args[1]));
|
|
}
|
|
break;
|
|
case 'seek':
|
|
if (args.length > 1) {
|
|
final seconds = double.tryParse(args[1]) ?? 0;
|
|
final mode = args.length > 2 ? args[2] : 'relative';
|
|
if (mode == 'absolute') {
|
|
await seek(Duration(milliseconds: (seconds * 1000).toInt()));
|
|
} else {
|
|
final newPos = state.position + Duration(milliseconds: (seconds * 1000).toInt());
|
|
await seek(newPos);
|
|
}
|
|
}
|
|
break;
|
|
case 'stop':
|
|
await stop();
|
|
break;
|
|
case 'sub-add':
|
|
if (args.length > 1) {
|
|
final select = args.length > 2 && args[2] == 'select';
|
|
await addSubtitleTrack(uri: args[1], select: select);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Apply subtitle styling to the native ExoPlayer layer.
|
|
///
|
|
/// 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,
|
|
required double borderSize,
|
|
required String borderColor,
|
|
required String bgColor,
|
|
required int bgOpacity,
|
|
int subtitlePosition = 100,
|
|
bool bold = false,
|
|
bool italic = false,
|
|
}) async {
|
|
if (disposed || !initialized) return;
|
|
await invoke('setSubtitleStyle', {
|
|
'fontSize': fontSize,
|
|
'textColor': textColor,
|
|
'borderSize': borderSize,
|
|
'borderColor': borderColor,
|
|
'bgColor': bgColor,
|
|
'bgOpacity': bgOpacity,
|
|
'subtitlePosition': subtitlePosition,
|
|
'bold': bold,
|
|
'italic': italic,
|
|
});
|
|
}
|
|
|
|
/// 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});
|
|
}
|
|
|
|
@override
|
|
Future<bool> setVideoFrameRate(
|
|
double fps,
|
|
int durationMs, {
|
|
int extraDelayMs = 0,
|
|
int videoWidth = 0,
|
|
int videoHeight = 0,
|
|
}) async {
|
|
if (disposed || !initialized) return false;
|
|
final result = await invoke<bool>('setVideoFrameRate', {
|
|
'fps': fps,
|
|
'duration': durationMs,
|
|
'extraDelayMs': extraDelayMs,
|
|
'videoWidth': videoWidth,
|
|
'videoHeight': videoHeight,
|
|
});
|
|
return result ?? false;
|
|
}
|
|
|
|
@override
|
|
Future<void> clearVideoFrameRate() async {
|
|
if (disposed || !initialized) return;
|
|
await invoke('clearVideoFrameRate');
|
|
}
|
|
|
|
@override
|
|
Future<void> updateFrame() async {
|
|
if (disposed || !initialized) return;
|
|
await invoke('updateFrame');
|
|
}
|
|
|
|
@override
|
|
Future<bool> requestAudioFocus() async {
|
|
if (disposed) return false;
|
|
await _ensureInitialized();
|
|
return await invoke<bool>('requestAudioFocus') ?? false;
|
|
}
|
|
|
|
@override
|
|
Future<void> abandonAudioFocus() async {
|
|
if (disposed || !initialized) return;
|
|
await invoke('abandonAudioFocus');
|
|
}
|
|
|
|
@override
|
|
Future<void> setLogLevel(String level) async {
|
|
if (disposed) return;
|
|
await invoke('setLogLevel', {'level': level});
|
|
}
|
|
}
|