feat(exoplayer): let the read-ahead buffer depth be chosen instead of fixed at 50s

ExoPlayer's DefaultLoadControl was built with hard-coded durations picked from
one memory tier, so read-ahead stopped at 50s on any device reporting 2GB or
less free, with no way to raise it. On hardware where mpv cannot render at all
that ceiling is the whole buffer budget.

Playback Buffer offers Auto, Large and Extra Large. The durations are taken
from jellyfin-androidtv and jellyfin-android so the same words mean the same
thing across Jellyfin clients; Auto keeps the memory-tiered values that
shipped. Named tiers rather than a duration because a duration would be a
promise the load control cannot keep: prioritizeTimeOverSizeThresholds is
disabled, so targetBufferBytes stops the loader even below minBufferMs and the
byte cap binds first above roughly 23 Mbit/s.

The tier crosses the method channel as a string and resolves in the core,
where an unrecognised name falls back to Auto. LoadControlPolicy clamps the
resulting pair: media3 validates the ordering with Guava Preconditions, an
unconditional throw R8 does not elide, so a bad pair would be an
IllegalArgumentException out of player construction rather than a bad buffer.

The two play-start thresholds stay fixed even though the Jellyfin tiers move
them. BufferingStallPolicy.MIN_BUFFER_AHEAD_MS is a const derived from
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, so a runtime value there would make the
stall watchdog indict a player that is obeying its own load control.

That leaves the byte target as a second, often smaller ceiling, and nothing
surfaced either. The resolved values now reach getStats, and the overlay's
Buffer section gains a Cache Limit row reading "120s / 128MB" next to the
buffered-ahead duration, so a tier that appears to do nothing on a
high-bitrate file explains itself.

close #1816
This commit is contained in:
edde746
2026-08-07 08:43:47 +02:00
parent f5ccaab3ab
commit 660e375248
37 changed files with 539 additions and 25 deletions
@@ -289,6 +289,15 @@ class ExoPlayerCore(private val activity: Activity) :
*/
private var observingLoadControl: ObservingLoadControl? = null
/**
* The read-ahead limits this session actually resolved to, kept so [getStats] can report them.
* Both ceilings matter and the smaller one binds: with `prioritizeTimeOverSizeThresholds`
* disabled the byte target stops the loader even below `minBufferMs`, so a raised Maximum
* Buffer that changes nothing on a UHD remux is explained by these two numbers side by side.
*/
private var resolvedTargetBufferBytes: Int? = null
private var resolvedBufferDurations: LoadControlPolicy.BufferDurations? = null
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -554,7 +563,11 @@ class ExoPlayerCore(private val activity: Activity) :
bufferSizeBytes: Int? = null,
bufferSizeAuto: Boolean = false,
tunnelingEnabled: Boolean = true,
audioPassthroughEnabled: Boolean = false
audioPassthroughEnabled: Boolean = false,
// Read-ahead depth, as the wire name Dart sends. Kept a String because `LoadControlPolicy`
// is internal and this function is not; unrecognised names resolve to Auto, which is also
// the default (#1816).
bufferTier: String = "auto"
): Boolean {
if (isInitialized) {
Log.d(TAG, "Already initialized")
@@ -777,30 +790,32 @@ class ExoPlayerCore(private val activity: Activity) :
LoadControlPolicy.autoTargetBufferBytes(largeHeapMB, availableMB)
}
val resolvedTier = LoadControlPolicy.BufferTier.fromWire(bufferTier)
val bufferDurations = LoadControlPolicy.bufferDurations(resolvedTier, availableMB)
resolvedTargetBufferBytes = targetBufferBytes
resolvedBufferDurations = bufferDurations
val loadControl = DefaultLoadControl.Builder().apply {
setTargetBufferBytes(targetBufferBytes)
setPrioritizeTimeOverSizeThresholds(false)
if (availableMB <= 2048) {
setBufferDurationsMs(
15_000,
50_000,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
} else {
setBufferDurationsMs(
30_000,
60_000,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
// Generic setter only. media3 1.9 added ...ForStreaming/...ForLocalPlayback variants and a
// latch that stops mirroring these into the local-playback fields the moment either is
// called, which would silently give file:// playback its own defaults.
setBufferDurationsMs(
bufferDurations.minBufferMs,
bufferDurations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}.build().let { ObservingLoadControl(it).also { observing -> observingLoadControl = observing } }
emitLog(
"info",
"init",
"Buffer: ${targetBufferBytes / 1024 / 1024}MB limit (${if (bufferSizeAuto) "auto" else "manual"}, " +
"heap=${largeHeapMB}MB, available=${availableMB}MB), tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
"heap=${largeHeapMB}MB, available=${availableMB}MB), " +
"buffer=${bufferDurations.minBufferMs / 1000}-${bufferDurations.maxBufferMs / 1000}s " +
"(${resolvedTier.name.lowercase()}), " +
"tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel"
)
exoPlayer = ExoPlayer.Builder(activity)
@@ -4132,6 +4147,10 @@ class ExoPlayerCore(private val activity: Activity) :
// Buffer metrics
"bufferedPositionMs" to player.bufferedPosition,
"currentPositionMs" to player.currentPosition,
// Both read-ahead ceilings. The smaller binds, so a Maximum Buffer that appears to do
// nothing on a high bitrate file is explained by the byte target sitting beside it.
"bufferTargetBytes" to resolvedTargetBufferBytes,
"bufferMaxMs" to resolvedBufferDurations?.maxBufferMs,
"totalBufferedDurationMs" to player.totalBufferedDuration,
// Playback state
"playbackSpeed" to player.playbackParameters.speed,
@@ -301,6 +301,9 @@ class ExoPlayerPlugin :
val audioPassthroughEnabled = call.argument<Boolean>("audioPassthroughEnabled") ?: false
val assVideoLatencyFrames = call.argument<Int>("assVideoLatencyFrames") ?: 0
val subtitleRenderScale = call.argument<Double>("subtitleRenderScale")?.toFloat() ?: 1.0f
// ExoPlayer-only: mpv's read-ahead is owned by the mpv.conf editor, so there is no
// fallback replay for this one. Resolved in the core; unrecognised means Auto (#1816).
val bufferTier = call.argument<String>("bufferTier") ?: "auto"
configuredBufferSizeBytes = bufferSizeBytes
// Seed the request here rather than waiting for Dart's separate setAudioPassthrough
// call, so a fallback raised before that arrives still derives audio-spdif correctly.
@@ -344,7 +347,8 @@ class ExoPlayerPlugin :
bufferSizeBytes = bufferSizeBytes,
bufferSizeAuto = bufferSizeAuto,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled
audioPassthroughEnabled = audioPassthroughEnabled,
bufferTier = bufferTier
)
if (!success) {
if (playerCore === core) playerCore = null
@@ -47,6 +47,84 @@ internal object LoadControlPolicy {
*/
private const val BUDGET_DIVISOR = 4
/**
* Memory tier below which the shipped Auto durations stay conservative, in
* `ActivityManager.MemoryInfo.availMem` MiB.
*/
private const val LOW_MEMORY_MB = 2048
/** Auto durations, by memory tier. These are the values that shipped before #1816. */
private const val AUTO_MIN_BUFFER_LOW_MEMORY_MS = 15_000
private const val AUTO_MAX_BUFFER_LOW_MEMORY_MS = 50_000
private const val AUTO_MIN_BUFFER_MS = 30_000
private const val AUTO_MAX_BUFFER_MS = 60_000
/**
* How deep the loader may read ahead.
*
* Named tiers rather than a duration, because a duration would be a promise this cannot keep:
* `prioritizeTimeOverSizeThresholds` is disabled, so `targetBufferBytes` stops the loader even
* below `minBufferMs`, and effective read-ahead is `min(maxBufferMs, 8 * bytes / bitrate)`. On a
* high bitrate stream the byte term wins whatever is chosen here. The tiers therefore describe
* an intent — how much network variance to absorb — and the Buffer Size setting bounds the
* memory it may cost.
*
* Values match jellyfin-androidtv and jellyfin-android so a user moving between Jellyfin clients
* gets the same behaviour from the same words.
*/
enum class BufferTier(val minBufferMs: Int, val maxBufferMs: Int) {
/** Memory-tiered defaults, resolved per device in [bufferDurations]. */
AUTO(0, 0),
/** For moderate or variable connections. */
LARGE(50_000, 120_000),
/** For slow or high-latency links, where a stall costs more than the memory does. */
EXTRA_LARGE(80_000, 240_000);
companion object {
/**
* Wire values are the Dart enum's `nativeValue`. An unknown string means a Dart/Kotlin
* skew, and [AUTO] is the only safe reading of "I do not know what this user asked for".
*/
fun fromWire(value: String?): BufferTier = when (value) {
"large" -> LARGE
"extra_large" -> EXTRA_LARGE
else -> AUTO
}
}
}
/** The min/max pair handed to `DefaultLoadControl.Builder.setBufferDurationsMs`. */
data class BufferDurations(val minBufferMs: Int, val maxBufferMs: Int)
/**
* Read-ahead duration bounds for this session.
*
* The returned pair always satisfies the ordering media3 asserts on
* (`bufferForPlayback* <= minBufferMs <= maxBufferMs`). That is enforced here rather than
* trusted, because media3 validates with Guava `Preconditions` — an unconditional throw that
* R8 does not elide — so a bad pair is an `IllegalArgumentException` out of
* `DefaultLoadControl.Builder.build()` on a user's device, not a degraded buffer.
*
* @param availableMB `ActivityManager.MemoryInfo.availMem`. Non-positive when unknown, which
* is treated as the low-memory tier.
*/
fun bufferDurations(tier: BufferTier, availableMB: Int): BufferDurations {
val playStartFloor = maxOf(BUFFER_FOR_PLAYBACK_MS, BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
val (rawMin, rawMax) = if (tier == BufferTier.AUTO) {
if (availableMB <= LOW_MEMORY_MB) {
AUTO_MIN_BUFFER_LOW_MEMORY_MS to AUTO_MAX_BUFFER_LOW_MEMORY_MS
} else {
AUTO_MIN_BUFFER_MS to AUTO_MAX_BUFFER_MS
}
} else {
tier.minBufferMs to tier.maxBufferMs
}
val minBufferMs = rawMin.coerceAtLeast(playStartFloor)
return BufferDurations(minBufferMs, rawMax.coerceAtLeast(minBufferMs))
}
/**
* @param largeHeapMB `ActivityManager.largeMemoryClass` — the hard Java-heap ceiling for
* this process, which is what bounds `DefaultAllocator` (it hands out `byte[]`).
@@ -0,0 +1,72 @@
package com.edde746.plezy.exoplayer
import androidx.media3.exoplayer.DefaultLoadControl
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* [LoadControlPolicyTest] checks that the policy's arithmetic satisfies the ordering we believe
* media3 requires. This file checks the belief, by handing every value the settings screen can
* produce to a real `DefaultLoadControl.Builder`.
*
* The distinction matters because media3 validates with Guava `Preconditions.checkArgument`, not
* `androidx.media3.common.util.Assertions` and not a JVM `assert`. It is an unconditional throw
* that survives R8 — nothing in `proguard-rules.pro` elides it — so a rejected pair is an
* `IllegalArgumentException` out of player construction on a user's device, not a degraded buffer.
* A media3 upgrade that tightens the ordering should fail here rather than there.
*/
@RunWith(RobolectricTestRunner::class)
class LoadControlPolicyBuildTest {
/** Both Auto memory tiers, their boundary, and the unknown-memory case. */
private val memoryTiers = intArrayOf(0, 512, 1024, 2048, 2049, 4096, 8192)
private fun build(tier: LoadControlPolicy.BufferTier, availableMB: Int): DefaultLoadControl {
val durations = LoadControlPolicy.bufferDurations(tier, availableMB)
return DefaultLoadControl.Builder()
.setTargetBufferBytes(LoadControlPolicy.MIN_TARGET_BYTES)
.setPrioritizeTimeOverSizeThresholds(false)
.setBufferDurationsMs(
durations.minBufferMs,
durations.maxBufferMs,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
@Test
fun everyTierBuildsOnEveryMemoryTier() {
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in memoryTiers) {
// build() throws IllegalArgumentException on a rejected ordering; reaching the next
// iteration is the assertion.
build(tier, availableMB)
}
}
}
@Test
fun aTierNameThisVersionDoesNotKnowStillBuilds() {
// The tier crosses a MethodChannel as a string, so the enum is not the only possible input:
// a Dart build newer than this native build can send a name that is not in `entries`.
for (wire in arrayOf(null, "", "gigantic", "EXTRA_LARGE", "0")) {
build(LoadControlPolicy.BufferTier.fromWire(wire), availableMB = 1024)
}
}
@Test
fun theShippedAutoTiersAreUnchanged() {
// #1816 adds a setting; it must not quietly retune the default everyone already has.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2048)
)
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 2049)
)
}
}
@@ -2,6 +2,7 @@ package com.edde746.plezy.exoplayer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private const val MIB = 1024 * 1024
@@ -84,4 +85,112 @@ class LoadControlPolicyTest {
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, 0L))
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
}
// bufferDurations
/**
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
* a JVM assert, so a bad pair takes down `DefaultLoadControl.Builder.build()` in release too.
* Every path through the policy has to satisfy it, including inputs no UI can produce.
*/
private fun assertBuildable(durations: LoadControlPolicy.BufferDurations) {
assertTrue(
"bufferForPlaybackMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS}) must not exceed " +
"minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS <= durations.minBufferMs
)
assertTrue(
"bufferForPlaybackAfterRebufferMs (${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS}) " +
"must not exceed minBufferMs (${durations.minBufferMs})",
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS <= durations.minBufferMs
)
assertTrue(
"minBufferMs (${durations.minBufferMs}) must not exceed maxBufferMs (${durations.maxBufferMs})",
durations.minBufferMs <= durations.maxBufferMs
)
}
@Test
fun autoKeepsTheConservativeTierOnALowMemoryDevice() {
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 1024)
)
}
@Test
fun autoKeepsTheRoomierTierWhenMemoryAllows() {
assertEquals(
LoadControlPolicy.BufferDurations(30_000, 60_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 4096)
)
}
@Test
fun unknownMemoryTakesTheConservativeTier() {
// availMem is reported as non-positive when unknown; guessing roomy there would be the
// wrong way to be wrong on the device that can least afford it.
assertEquals(
LoadControlPolicy.BufferDurations(15_000, 50_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.AUTO, availableMB = 0)
)
}
@Test
fun anExplicitTierIgnoresTheMemoryTier() {
// The whole point of #1816: a low-memory box may still want a deep buffer, because the
// constraint it is working around is the network, not the heap. Auto is the only mode that
// gets to consult memory.
assertEquals(
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 8192),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 512)
)
}
@Test
fun theTiersMatchTheJellyfinClients() {
// Borrowed verbatim from jellyfin-androidtv BufferLength and jellyfin-android
// PlayerViewModel so the same words mean the same thing across Jellyfin clients.
// Changing these silently diverges Plezy from that shared vocabulary.
assertEquals(
LoadControlPolicy.BufferDurations(50_000, 120_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.LARGE, availableMB = 4096)
)
assertEquals(
LoadControlPolicy.BufferDurations(80_000, 240_000),
LoadControlPolicy.bufferDurations(LoadControlPolicy.BufferTier.EXTRA_LARGE, availableMB = 4096)
)
}
@Test
fun everyTierProducesABuildablePairOnEveryMemoryTier() {
// A pair media3 rejects is a crash on play, not a bad buffer.
for (tier in LoadControlPolicy.BufferTier.entries) {
for (availableMB in intArrayOf(0, 512, 1024, 2048, 4096, 8192)) {
assertBuildable(LoadControlPolicy.bufferDurations(tier, availableMB))
}
}
}
// BufferTier.fromWire
@Test
fun theWireNamesMatchTheDartNativeValues() {
assertEquals(LoadControlPolicy.BufferTier.LARGE, LoadControlPolicy.BufferTier.fromWire("large"))
assertEquals(
LoadControlPolicy.BufferTier.EXTRA_LARGE,
LoadControlPolicy.BufferTier.fromWire("extra_large")
)
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("auto"))
}
@Test
fun anUnrecognisedOrAbsentTierFallsBackToAuto() {
// A Dart/Kotlin skew must degrade to the shipped defaults, not to a deep buffer nobody
// asked for on a device that may not afford it.
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(null))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire(""))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("EXTRA_LARGE"))
assertEquals(LoadControlPolicy.BufferTier.AUTO, LoadControlPolicy.BufferTier.fromWire("extraLarge"))
}
}