Adds an "Anchor to Screen" toggle under Subtitle Styling (Android + ExoPlayer only, default off). When enabled, the text SubtitleView is sized to the full container instead of the letterboxed video rect, so SRT/VTT/mov_text cues render in the black bars below widescreen video and font size and the position setting become relative to the physical screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged; mpv already places plaintext subtitles in the margins by default. close #1730
627 lines
21 KiB
Dart
627 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,
|
|
bool anchorToScreen = 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,
|
|
'anchorToScreen': anchorToScreen,
|
|
});
|
|
}
|
|
|
|
/// 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});
|
|
}
|
|
}
|