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.
This commit is contained in:
edde746
2026-08-01 06:59:21 +02:00
parent 3f49bcabf8
commit 395798f28e
7 changed files with 266 additions and 12 deletions
@@ -12,6 +12,7 @@ class PlayerAndroid extends PlayerBase {
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
int? _bufferSizeBytes;
bool _bufferSizeIsAuto = false;
bool _tunnelingEnabled = true;
String _dvConversionMode = 'auto';
bool _audioNormalizationEnabled = false;
@@ -100,6 +101,7 @@ class PlayerAndroid extends PlayerBase {
try {
final result = await invoke<bool>('initialize', {
'bufferSizeBytes': _bufferSizeBytes,
'bufferSizeAuto': _bufferSizeIsAuto,
'tunnelingEnabled': _tunnelingEnabled,
'dvConversionMode': _dvConversionMode,
'audioPassthroughEnabled': _audioPassthroughEnabled,
@@ -287,6 +289,12 @@ class PlayerAndroid extends PlayerBase {
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;