Focus chrome was implemented twice, once in the focusable wrapper and once in the focus builders; both now go through FocusChrome. TvColorPicker's channel row was a copy of TvNumberSpinner and is now that widget in compact density. Also trims unused helpers and fields and simplifies the Jellyfin browse paths.
565 lines
18 KiB
Dart
565 lines
18 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 _tunnelingEnabled = true;
|
|
String _dvConversionMode = 'auto';
|
|
bool _audioNormalizationEnabled = false;
|
|
bool _audioPassthroughEnabled = false;
|
|
bool _downmixEnabled = false;
|
|
int _downmixCenterBoostDb = 0;
|
|
bool _downmixNormalize = true;
|
|
|
|
static const String _passthroughCodecs = 'ac3,eac3,dts,dts-hd,truehd';
|
|
|
|
/// 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;
|
|
|
|
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,
|
|
'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 (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});
|
|
}
|
|
|
|
@override
|
|
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
|
await invoke('selectSubtitleTrack', {'trackId': track.id});
|
|
}
|
|
|
|
@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});
|
|
}
|
|
|
|
@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;
|
|
case 'tunneled-playback':
|
|
_tunnelingEnabled = value != 'no';
|
|
break;
|
|
case 'dv-conversion-mode':
|
|
_dvConversionMode = value;
|
|
await _applyWhenInitialized(
|
|
() => invoke('setDvConversionMode', {'mode': value}),
|
|
() => _dvConversionMode == value,
|
|
);
|
|
break;
|
|
case 'sub-visibility':
|
|
if (value == 'no') {
|
|
final current = state.track.subtitle;
|
|
if (current != null && current.id != 'no') {
|
|
_hiddenSubtitleTrackId = current.id;
|
|
await selectSubtitleTrack(SubtitleTrack.off);
|
|
}
|
|
} else {
|
|
final storedId = _hiddenSubtitleTrackId;
|
|
if (storedId != null) {
|
|
_hiddenSubtitleTrackId = null;
|
|
final track = state.tracks.subtitle.firstWhereOrNull((t) => t.id == storedId);
|
|
if (track != null) {
|
|
await selectSubtitleTrack(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,
|
|
);
|
|
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
|
|
}
|
|
|
|
@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});
|
|
}
|
|
}
|