From 395798f28ea36f8122496006e00a68cc8b966fef Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:39:55 +0200 Subject: [PATCH] fix(player): stop handing ExoPlayer the demuxer's buffer budget on Auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 27 +++--- .../plezy/exoplayer/ExoPlayerPlugin.kt | 5 ++ .../plezy/exoplayer/LoadControlPolicy.kt | 60 +++++++++++++ .../plezy/exoplayer/LoadControlPolicyTest.kt | 87 +++++++++++++++++++ lib/mpv/player/platform/player_android.dart | 8 ++ lib/screens/video_player_screen.dart | 5 ++ test/mpv/player_android_buffer_size_test.dart | 86 ++++++++++++++++++ 7 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt create mode 100644 test/mpv/player_android_buffer_size_test.dart diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index fa994cd8..f063483d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -523,6 +523,7 @@ class ExoPlayerCore(private val activity: Activity) : fun initialize( bufferSizeBytes: Int? = null, + bufferSizeAuto: Boolean = false, tunnelingEnabled: Boolean = true, audioPassthroughEnabled: Boolean = false ): Boolean { @@ -731,23 +732,20 @@ class ExoPlayerCore(private val activity: Activity) : .toTypedArray() } - // Compute memory-aware buffer limits to prevent CCodec OOM crashes + // Buffer budget. `bufferSizeBytes` carries the user's explicit Buffer Size choice; on + // Auto it still arrives (Dart derives it for mpv's demuxer, which shares the property) + // but `bufferSizeAuto` says to ignore it here, because mpv's demuxer and ExoPlayer's + // sample allocator have different shapes and different failure modes. val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val memoryInfo = ActivityManager.MemoryInfo() activityManager.getMemoryInfo(memoryInfo) - val availableMB = memoryInfo.availMem / (1024 * 1024) + val availableMB = (memoryInfo.availMem / (1024 * 1024)).toInt() + val largeHeapMB = activityManager.largeMemoryClass - val targetBufferBytes = if (bufferSizeBytes != null && bufferSizeBytes > 0) { + val targetBufferBytes = if (!bufferSizeAuto && bufferSizeBytes != null && bufferSizeBytes > 0) { bufferSizeBytes } else { - // Scale buffer to available memory to reduce hardware decoder pressure. - // Larger buffers reduce oscillation frequency at high bitrates (50-100Mbps). - when { - availableMB <= 512 -> 30 * 1024 * 1024 - availableMB <= 1024 -> 80 * 1024 * 1024 - availableMB <= 2048 -> 120 * 1024 * 1024 - else -> 200 * 1024 * 1024 - } + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB) } val loadControl = DefaultLoadControl.Builder().apply { @@ -759,7 +757,12 @@ class ExoPlayerCore(private val activity: Activity) : setBufferDurationsMs(30_000, 60_000, 1_000, 5_000) } }.build() - emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel") + emitLog( + "info", + "init", + "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " + + "heap=${largeHeapMB}MB, available=${availableMB}MB), tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel" + ) exoPlayer = ExoPlayer.Builder(activity) .setTrackSelector(trackSelector!!) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 581dff76..cd14fb6c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -285,6 +285,10 @@ class ExoPlayerPlugin : } val bufferSizeBytes = call.argument("bufferSizeBytes") + // Auto sizing is decided natively (LoadControlPolicy). `bufferSizeBytes` still arrives + // on Auto because Dart derives one for mpv's demuxer, which shares the property, and + // the fallback replay below needs it. + val bufferSizeAuto = call.argument("bufferSizeAuto") ?: false val tunnelingEnabled = call.argument("tunnelingEnabled") ?: true val dvConversionMode = call.argument("dvConversionMode") ?: "auto" val audioPassthroughEnabled = call.argument("audioPassthroughEnabled") ?: false @@ -323,6 +327,7 @@ class ExoPlayerPlugin : playerCore = core val success = core.initialize( bufferSizeBytes = bufferSizeBytes, + bufferSizeAuto = bufferSizeAuto, tunnelingEnabled = tunnelingEnabled, audioPassthroughEnabled = audioPassthroughEnabled ) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt new file mode 100644 index 00000000..d4fb68c6 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt @@ -0,0 +1,60 @@ +package com.edde746.plezy.exoplayer + +/** + * Auto sizing for [androidx.media3.exoplayer.DefaultLoadControl]'s `targetBufferBytes` (#1618). + * + * A byte cap collapses as bitrate rises: 64 MiB 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, so + * read-ahead that short starves the audio sink in bursts — enough, on some routes, to keep a + * passthrough AudioTrack from ever starting. + * + * The tiers this replaced came from mpv demuxer OOM tuning and handed ExoPlayer a flat + * 64 MiB, under half of what media3 would pick for the same selection + * ([MEDIA3_DEFAULT_TARGET_BYTES]). The fix is not "buffer more than upstream", it is "stop + * buffering less unless the heap requires it". + * + * Not bitrate-aware: the `LoadControl` is built during `initialize`, before any media is + * opened, so a byte budget is all that is knowable. + */ +internal object LoadControlPolicy { + private const val MIB = 1024 * 1024 + + /** + * media3's own `calculateTargetBufferBytes` for a video + audio selection: 2000 + 200 + * segments at `C.DEFAULT_BUFFER_SEGMENT_SIZE` (64 KiB). A ceiling, never exceeded here. + */ + const val MEDIA3_DEFAULT_TARGET_BYTES = 2200 * 64 * 1024 + + /** + * Floor, kept at the lowest tier that has already shipped, and it wins over the budgets + * below — going under it reintroduces the starvation this policy exists to prevent. + */ + const val MIN_TARGET_BYTES = 32 * MIB + + /** + * Fraction of a memory budget the allocator may claim. Matches the threshold the Buffer + * Size setting already warns at (`value > heapMB / 4`). + */ + private const val BUDGET_DIVISOR = 4 + + /** + * @param largeHeapMB `ActivityManager.largeMemoryClass` — the hard Java-heap ceiling for + * this process, which is what bounds `DefaultAllocator` (it hands out `byte[]`). + * Non-positive when unknown. + * @param availableMB `ActivityManager.MemoryInfo.availMem`, so a device that is currently + * under pressure does not get sized purely off its theoretical heap. Non-positive when + * unknown. + */ + fun autoTargetBufferBytes(largeHeapMB: Int, availableMB: Int): Int { + var budget = MEDIA3_DEFAULT_TARGET_BYTES.toLong() + if (largeHeapMB > 0) budget = minOf(budget, largeHeapMB.toLong() / BUDGET_DIVISOR * MIB) + if (availableMB > 0) budget = minOf(budget, availableMB.toLong() / BUDGET_DIVISOR * MIB) + return maxOf(budget, MIN_TARGET_BYTES.toLong()).toInt() + } + + /** Seconds of media a budget covers at [bitrateBps], for logs. Null when unknown. */ + fun readAheadSeconds(targetBufferBytes: Int, bitrateBps: Long): Double? { + if (bitrateBps <= 0L) return null + return targetBufferBytes * 8.0 / bitrateBps + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt new file mode 100644 index 00000000..01e7336f --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt @@ -0,0 +1,87 @@ +package com.edde746.plezy.exoplayer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +private const val MIB = 1024 * 1024 + +class LoadControlPolicyTest { + + // autoTargetBufferBytes + + @Test + fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() { + assertEquals( + LoadControlPolicy.MEDIA3_DEFAULT_TARGET_BYTES, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = 4096, availableMB = 8192) + ) + } + + @Test + fun heapBoundsTheTargetOnAShieldClassDevice() { + // The actual #1618 defect: the shipped tiers handed this device a flat 64MB, under half + // of media3's own choice. largeMemoryClass 512MB, ~1GB free, so the heap binds at + // 512/4 = 128MB — exactly what the reporter had to select by hand. + assertEquals( + 128 * MIB, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = 512, availableMB = 990) + ) + } + + @Test + fun freeMemoryBoundsTheTargetWhenItIsTighterThanTheHeap() { + assertEquals( + 64 * MIB, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = 512, availableMB = 256) + ) + } + + @Test + fun floorWinsOverBothBudgetsSoReadAheadCannotCollapse() { + // Going under the floor is what starves the sink on high-bitrate content; the + // allocator only grows into the target when the content is dense enough to need it. + assertEquals( + LoadControlPolicy.MIN_TARGET_BYTES, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = 64, availableMB = 48) + ) + } + + @Test + fun unknownMemoryFallsBackToMedia3sTarget() { + assertEquals( + LoadControlPolicy.MEDIA3_DEFAULT_TARGET_BYTES, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = 0, availableMB = 0) + ) + } + + @Test + fun unknownHeapStillRespectsFreeMemory() { + assertEquals( + 64 * MIB, + LoadControlPolicy.autoTargetBufferBytes(largeHeapMB = -1, availableMB = 256) + ) + } + + // readAheadSeconds + + @Test + fun readAheadReportsSecondsAtAKnownBitrate() { + // 64MiB of the #1618 stream (103_341 kbps) is ~5.2s — under the 15s minBufferMs, so the + // byte cap, not the time threshold, is what stops the loader. + val seconds = LoadControlPolicy.readAheadSeconds(64 * MIB, 103_341_000L)!! + assertEquals(5.19, seconds, 0.01) + } + + @Test + fun readAheadDoublesWithTheTarget() { + val seconds = LoadControlPolicy.readAheadSeconds(128 * MIB, 103_341_000L)!! + assertEquals(10.39, seconds, 0.01) + } + + @Test + fun readAheadIsUnknownWithoutABitrate() { + assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, 0L)) + assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L)) + } +} diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index c00cd72f..25a76cfb 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -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('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; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 81db9d3c..a6d796fe 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1135,6 +1135,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin } await currentPlayer.setProperty('demuxer-max-bytes', '${autoForwardMB * 1024 * 1024}'); await currentPlayer.setProperty('demuxer-max-back-bytes', '${autoBackMB * 1024 * 1024}'); + // These tiers size mpv's demuxer. ExoPlayer's LoadControl allocator is a + // different consumer — a flat byte cap there collapses to a few seconds of + // read-ahead on a 100 Mbps remux — so let the native side derive its own + // target on Auto (#1618). + await currentPlayer.setProperty('demuxer-max-bytes-auto', 'yes'); } else { // Manual mode: cap back-buffer relative to heap if 1/4 ratio is too high final maxBackBytes = min(bufferSizeMB * 1024 * 1024 ~/ 4, autoBackMB * 1024 * 1024); diff --git a/test/mpv/player_android_buffer_size_test.dart b/test/mpv/player_android_buffer_size_test.dart new file mode 100644 index 00000000..d60486e9 --- /dev/null +++ b/test/mpv/player_android_buffer_size_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/mpv/player/platform/player_android.dart'; +import 'package:plezy/services/settings_service.dart'; + +import '../test_helpers/mock_player_channels.dart'; +import '../test_helpers/prefs.dart'; + +/// Drives the Buffer Size contract between Dart and `ExoPlayerPlugin` (#1618). +/// +/// Auto still sends `demuxer-max-bytes`, because mpv's demuxer shares the property and the +/// plugin's mpv fallback replays it. Only `bufferSizeAuto` tells the native side that the +/// value was derived for the demuxer and that `LoadControlPolicy` should size ExoPlayer's +/// allocator instead. Getting that flag wrong is silent: playback still works, just with the +/// 64MB cap that starves a high-bitrate passthrough track. +Future _captureInitialize({required Future Function(PlayerAndroid player) configure}) async { + late MethodCall initialize; + await withMockPlayerChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) async { + if (call.method == 'initialize') initialize = call; + return call.method == 'initialize' ? true : null; + }, + testBody: () async { + final player = PlayerAndroid(); + try { + await configure(player); + // requestAudioFocus is what actually triggers native initialize; the screen relies + // on that ordering so every setProperty above is cached first. + await player.requestAudioFocus(); + } finally { + await player.dispose(); + } + }, + ); + return initialize; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + test('Auto tells the native side to size its own LoadControl target', () async { + final initialize = await _captureInitialize( + configure: (player) async { + // What video_player_screen sends on Auto for a 512MB-heap device. + await player.setProperty('demuxer-max-bytes', '${64 * 1024 * 1024}'); + await player.setProperty('demuxer-max-bytes-auto', 'yes'); + }, + ); + + final args = initialize.arguments as Map; + expect(args['bufferSizeAuto'], isTrue); + // Still forwarded: the plugin's mpv fallback replays it as a real demuxer property. + expect(args['bufferSizeBytes'], 64 * 1024 * 1024); + }); + + test('an explicit Buffer Size choice is never overridden by Auto sizing', () async { + final initialize = await _captureInitialize( + configure: (player) async { + await player.setProperty('demuxer-max-bytes', '${128 * 1024 * 1024}'); + }, + ); + + final args = initialize.arguments as Map; + expect(args['bufferSizeAuto'], isFalse); + expect(args['bufferSizeBytes'], 128 * 1024 * 1024); + }); + + test('an unknown heap leaves no byte cap, so the native side still picks Auto', () async { + // video_player_screen skips the whole tier block when getHeapSize() fails, so neither + // property is ever set. bufferSizeBytes must stay null rather than defaulting to a + // number the native side would treat as a deliberate choice. + final initialize = await _captureInitialize(configure: (_) async {}); + + final args = initialize.arguments as Map; + expect(args['bufferSizeAuto'], isFalse); + expect(args['bufferSizeBytes'], isNull); + }); +}