Files
plezy/lib/mpv/player/platform/player_android.dart
T
edde746 395798f28e fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto
On Auto, Dart derives a buffer size for mpv's demuxer from the device heap and
sets it as `demuxer-max-bytes`. The Android player forwarded that same number to
`DefaultLoadControl.setTargetBufferBytes`, so ExoPlayer's sample allocator was
sized by a tier table written for a different consumer: 64MB on any device whose
large heap is 512MB or less, which every Shield is.

`targetBufferBytes` is a byte cap, so the media it represents collapses as
bitrate rises — 64MB is 53s of a 10 Mbit/s stream but 5.2s of a 103 Mbit/s UHD
remux. With `prioritizeTimeOverSizeThresholds` false the cap is hard:
`shouldContinueLoading` returns false the moment the allocator reaches it no
matter how little media that is, and `shouldStartPlayback` reports READY off the
same byte term. Read-ahead that short starves the audio sink in bursts, and on a
passthrough route that is enough to keep the AudioTrack from ever starting — the
track initializes, accepts one access unit and never renders a frame. Because an
enabled audio renderer owns the MediaClock, the whole player freezes and the
black-screen watchdog then blames the video decoder and drops the session to
mpv.

Size the LoadControl target natively instead, from what actually bounds
`DefaultAllocator`: the Java heap. `min(media3's own default for a video+audio
selection, largeMemoryClass/4, availMem/4)` with a 32MB floor, the lowest tier
that has already shipped. The quarter matches the threshold the Buffer Size
setting already warns at, and the media3 default is a ceiling — this is not
"buffer more than upstream", it is "stop buffering less". Deliberately not
bitrate-aware, because the LoadControl is built during initialize, before any
media is opened. `bufferSizeAuto` carries the distinction over the channel;
`bufferSizeBytes` still travels with it because the plugin's mpv fallback
replays it as a real demuxer property, and an explicit Buffer Size choice is
still honoured verbatim.

Confirmed against the hardware in the 2.9.1 passthrough report. That reporter's
own log is a natural A/B: three runs at 64MB fail with `0 frames rendered after
8002ms`, spanning both DV conversion modes and both tunneling states, while the
single run after he manually selected 128MB logs `Position advancing` and
renders. Reproduced on the same Shield model with codec and bitrate held fixed
and only the cap varied — 6s of audio demand stalls at 64MiB and plays at
128MiB, 4 of 4 predictions, with read-ahead measured off an injected
DefaultAllocator at 65 664 and 131 776 KiB. That device reports
`dalvik.vm.heapsize` 512m, so the heap term binds first at every free-memory
level in his log and Auto now derives exactly the 128MB he had to pick by hand;
the shipped path logs `Buffer: 128MB limit (auto, heap=512MB, available=568MB)`
where it previously logged 64MB.
2026-08-01 06:59:21 +02:00

575 lines
19 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;
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<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,
'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;
// 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<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});
}
}