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; bool _tunnelingEnabled = true; String _dvConversionMode = 'auto'; bool _audioNormalizationEnabled = false; bool _audioPassthroughEnabled = false; bool _downmixEnabled = false; int _downmixCenterBoostDb = 0; bool _downmixNormalize = true; /// 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? _initFuture; Future _ensureInitialized() async { if (initialized) return; return _initFuture ??= _doInitialize(); } Future _doInitialize() async { try { final result = await invoke('initialize', { 'bufferSizeBytes': _bufferSizeBytes, 'bufferSizeAuto': _bufferSizeIsAuto, '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 _applyWhenInitialized(Future 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 open( Media media, { bool play = true, bool isLive = false, List? 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 play() async { await invoke('play'); } @override Future pause() async { await invoke('pause'); } @override Future stop() async { await invoke('stop'); setSeekable(false); await setVisible(false); } @override Future seek(Duration position) async { await runSeek(position, () => invoke('seek', {'positionMs': position.inMilliseconds})); } @override Future selectAudioTrack(AudioTrack track) async { await invoke('selectAudioTrack', {'trackId': track.id}); } @override Future selectSubtitleTrack(SubtitleTrack track) async { await invoke('selectSubtitleTrack', {'trackId': track.id}); } @override Future 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 setVolume(double volume) async { await invoke('setVolume', {'volume': volume}); if (!disposed) setVolumeState(volume); } @override Future setRate(double rate) async { await invoke('setRate', {'rate': rate}); } @override Future 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; 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 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 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 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 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> getStats() async { if (disposed) return {}; try { final result = await invoke('getStats'); return Map.from(result ?? {}); } catch (e) { return {}; } } /// Returns the device's large heap size in MB, or 0 if unavailable (Android only). static Future getHeapSize() async { try { final result = await _methodChannel.invokeMethod('getHeapSize'); return result ?? 0; } catch (e) { return 0; } } @override Future runtimePlayerType() async { if (disposed) return 'unknown'; try { final result = await invoke('getPlayerType'); return result ?? 'unknown'; } catch (e) { return 'unknown'; } } @override Future command(List 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 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 setBoxFitMode(int mode) async { if (disposed || !initialized) return; await invoke('setBoxFitMode', {'mode': mode}); } /// Apply custom zoom to the native ExoPlayer layer. @override Future setVideoZoom(double scale) async { if (disposed || !initialized) return; await invoke('setVideoZoom', {'scale': scale}); } @override Future setVideoFrameRate( double fps, int durationMs, { int extraDelayMs = 0, int videoWidth = 0, int videoHeight = 0, }) async { if (disposed || !initialized) return false; final result = await invoke('setVideoFrameRate', { 'fps': fps, 'duration': durationMs, 'extraDelayMs': extraDelayMs, 'videoWidth': videoWidth, 'videoHeight': videoHeight, }); return result ?? false; } @override Future clearVideoFrameRate() async { if (disposed || !initialized) return; await invoke('clearVideoFrameRate'); } @override Future updateFrame() async { if (disposed || !initialized) return; await invoke('updateFrame'); } @override Future requestAudioFocus() async { if (disposed) return false; await _ensureInitialized(); return await invoke('requestAudioFocus') ?? false; } @override Future abandonAudioFocus() async { if (disposed || !initialized) return; await invoke('abandonAudioFocus'); } @override Future setLogLevel(String level) async { if (disposed) return; await invoke('setLogLevel', {'level': level}); } }