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); + }); +}