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 f1ff08c4..e751e55f 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 @@ -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, 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 1a2865b8..d84919e2 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 @@ -301,6 +301,9 @@ class ExoPlayerPlugin : val audioPassthroughEnabled = call.argument("audioPassthroughEnabled") ?: false val assVideoLatencyFrames = call.argument("assVideoLatencyFrames") ?: 0 val subtitleRenderScale = call.argument("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("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 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 index 9b76c503..33d5101b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicy.kt @@ -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[]`). diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyBuildTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyBuildTest.kt new file mode 100644 index 00000000..6af57826 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyBuildTest.kt @@ -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) + ) + } +} 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 index 01e7336f..9c7e4136 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/LoadControlPolicyTest.kt @@ -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")) + } } diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index a5594873..33510061 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Tünd", "oledTheme": "OLED", "libraryDensity": "Kitabxana sıxlığı", + "displayScale": "", "compact": "Sıx", "comfortable": "Rəhat", "tvCornerSpotlightBackdrop": "Künc işıqlandırma fonu", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Avtomatik (Tövsiyə olunan)", "bufferSizeWarning": "${heap}MB yaddaş əlçatandır. ${size}MB bufer oynatmaya təsir edə bilər.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Defolt keyfiyyət", "musicQualityTitle": "Musiqi keyfiyyəti", "subtitleStyling": "Altyazı tənzimləmələri", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index e15e9595..986f420d 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Тъмна", "oledTheme": "OLED", "libraryDensity": "Плътност на библиотеката", + "displayScale": "", "compact": "Компактна", "comfortable": "Удобна", "tvCornerSpotlightBackdrop": "Фон с акцент в ъгъла", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Автоматично (препоръчително)", "bufferSizeWarning": "Налична памет: ${heap} MB. Буфер от ${size} MB може да повлияе на възпроизвеждането.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Качество по подразбиране", "musicQualityTitle": "Качество на музиката", "subtitleStyling": "Стил на субтитрите", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index f9821411..c103fc10 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Mørk", "oledTheme": "OLED", "libraryDensity": "Bibliotekstæthed", + "displayScale": "", "compact": "Kompakt", "comfortable": "Komfortabel", "tvCornerSpotlightBackdrop": "Fremhævet baggrundsbillede i hjørnet", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Automatisk (anbefalet)", "bufferSizeWarning": "${heap} MB hukommelse tilgængelig. En buffer på ${size} MB kan påvirke afspilningen.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standardkvalitet", "musicQualityTitle": "Musikkvalitet", "subtitleStyling": "Undertekststil", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 3496ebd6..f1d6243b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Dunkel", "oledTheme": "OLED", "libraryDensity": "Darstellungsdichte der Mediathek", + "displayScale": "", "compact": "Kompakt", "comfortable": "Großzügig", "tvCornerSpotlightBackdrop": "Backdrop in der Ecke", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Automatisch (empfohlen)", "bufferSizeWarning": "${heap} MB Speicher verfügbar. Ein Puffer von ${size} MB kann die Wiedergabe beeinträchtigen.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standardqualität", "musicQualityTitle": "Musikqualität", "subtitleStyling": "Untertitel-Stil", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 9f78fb05..4f0eebf7 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -170,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Auto (Recommended)", "bufferSizeWarning": "${heap}MB memory available. A ${size}MB buffer may affect playback.", + "playbackBuffer": "Playback Buffer", + "playbackBufferAuto": "Auto (Recommended)", + "playbackBufferLarge": "Large", + "playbackBufferExtraLarge": "Extra Large", + "playbackBufferDescription": "Buffer more against unstable connections. Also limited by Buffer Size.", "defaultQualityTitle": "Default Quality", "musicQualityTitle": "Music Quality", "subtitleStyling": "Subtitle Styling", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index db9a0355..6b01545d 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Oscuro", "oledTheme": "OLED", "libraryDensity": "Densidad de la biblioteca", + "displayScale": "", "compact": "Compacto", "comfortable": "Cómodo", "tvCornerSpotlightBackdrop": "Imagen destacada en la esquina", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Automático (recomendado)", "bufferSizeWarning": "${heap} MB de memoria disponible. Un búfer de ${size} MB puede afectar a la reproducción.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Calidad predeterminada", "musicQualityTitle": "Calidad de música", "subtitleStyling": "Estilo de subtítulos", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 63e1906b..dd3d66f5 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Sombre", "oledTheme": "OLED", "libraryDensity": "Densité des bibliothèques", + "displayScale": "", "compact": "Compact", "comfortable": "Confortable", "tvCornerSpotlightBackdrop": "Illustration en vedette dans le coin", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} Mo", "bufferSizeAuto": "Automatique (recommandé)", "bufferSizeWarning": "${heap} Mo de mémoire disponible. Un tampon de ${size} Mo peut affecter la lecture.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Qualité par défaut", "musicQualityTitle": "Qualité de la musique", "subtitleStyling": "Style des sous-titres", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 38253931..1fca0bf5 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Sötét", "oledTheme": "OLED", "libraryDensity": "Könyvtársűrűség", + "displayScale": "", "compact": "Kompakt", "comfortable": "Kényelmes", "tvCornerSpotlightBackdrop": "Sarokban megjelenő kiemelt háttérkép", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Automatikus (ajánlott)", "bufferSizeWarning": "${heap} MB memória érhető el. A(z) ${size} MB méretű puffer befolyásolhatja a lejátszást.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Alapértelmezett minőség", "musicQualityTitle": "Zene minősége", "subtitleStyling": "Feliratok stílusa", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index faceac7c..9d62b3b3 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Scuro", "oledTheme": "OLED", "libraryDensity": "Densità della libreria", + "displayScale": "", "compact": "Compatta", "comfortable": "Comoda", "tvCornerSpotlightBackdrop": "Sfondo in evidenza nell'angolo", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Automatica (consigliata)", "bufferSizeWarning": "${heap}MB di memoria disponibile. Un buffer di ${size}MB può influire sulla riproduzione.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Qualità predefinita", "musicQualityTitle": "Qualità musicale", "subtitleStyling": "Stile sottotitoli", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index d7d66686..e6794cef 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "ダーク", "oledTheme": "OLED", "libraryDensity": "ライブラリの密度", + "displayScale": "", "compact": "コンパクト", "comfortable": "ゆったり", "tvCornerSpotlightBackdrop": "画面隅の注目コンテンツ背景", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "自動(推奨)", "bufferSizeWarning": "${heap}MBのメモリが利用可能です。${size}MBのバッファは再生に影響する可能性があります。", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "デフォルト画質", "musicQualityTitle": "音楽の音質", "subtitleStyling": "字幕スタイル", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 5ddb8050..84709c15 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Қараңғы", "oledTheme": "OLED", "libraryDensity": "Кітапхана тығыздығы", + "displayScale": "", "compact": "Тығыз", "comfortable": "Ыңғайлы", "tvCornerSpotlightBackdrop": "Бұрыштық жарық фоны", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} МБ", "bufferSizeAuto": "Автоматты (Ұсынылатын)", "bufferSizeWarning": "${heap} МБ ЖҰД қолжетімді. ${size} МБ буфер ойнатуға әсер етуі мүмкін.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Әдепкі сапа", "musicQualityTitle": "Музыка сапасы", "subtitleStyling": "Субтитр баптаулары", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 03d51a3d..18c3f9b9 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "다크", "oledTheme": "OLED", "libraryDensity": "라이브러리 표시 밀도", + "displayScale": "", "compact": "조밀하게", "comfortable": "여유롭게", "tvCornerSpotlightBackdrop": "모서리 스포트라이트 배경", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "자동 (권장)", "bufferSizeWarning": "${heap}MB 메모리를 사용할 수 있습니다. ${size}MB 버퍼는 재생에 영향을 줄 수 있습니다.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "기본 화질", "musicQualityTitle": "음악 음질", "subtitleStyling": "자막 스타일", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index f39e19d5..2ef01267 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Mørkt", "oledTheme": "OLED", "libraryDensity": "Innholdstetthet i biblioteket", + "displayScale": "", "compact": "Kompakt", "comfortable": "Komfortabel", "tvCornerSpotlightBackdrop": "Fremhevet bakgrunn i hjørnet", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Automatisk (anbefalt)", "bufferSizeWarning": "${heap} MB minne tilgjengelig. En buffer på ${size} MB kan påvirke avspillingen.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standardkvalitet", "musicQualityTitle": "Musikkvalitet", "subtitleStyling": "Undertekststil", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index fec26e89..ec740177 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Donker", "oledTheme": "OLED", "libraryDensity": "Bibliotheekdichtheid", + "displayScale": "", "compact": "Compact", "comfortable": "Comfortabel", "tvCornerSpotlightBackdrop": "Uitgelichte achtergrond in de hoek", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Automatisch (aanbevolen)", "bufferSizeWarning": "${heap}MB geheugen beschikbaar. Een buffer van ${size}MB kan afspelen beïnvloeden.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standaardkwaliteit", "musicQualityTitle": "Muziekkwaliteit", "subtitleStyling": "Ondertitelopmaak", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 70e83724..3c57638b 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Ciemny", "oledTheme": "OLED", "libraryDensity": "Gęstość biblioteki", + "displayScale": "", "compact": "Kompaktowy", "comfortable": "Wygodny", "tvCornerSpotlightBackdrop": "Tło wyróżnionej pozycji w rogu", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Automatyczny (zalecany)", "bufferSizeWarning": "Dostępna pamięć: ${heap}MB. Bufor ${size}MB może wpłynąć na odtwarzanie.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Domyślna jakość", "musicQualityTitle": "Jakość muzyki", "subtitleStyling": "Styl napisów", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index ddb9765d..a94100ee 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Escuro", "oledTheme": "OLED", "libraryDensity": "Densidade da Biblioteca", + "displayScale": "", "compact": "Compacto", "comfortable": "Confortável", "tvCornerSpotlightBackdrop": "Imagem de destaque no canto", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Automático (Recomendado)", "bufferSizeWarning": "${heap}MB de memória disponível. Um buffer de ${size}MB pode afetar a reprodução.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Qualidade padrão", "musicQualityTitle": "Qualidade da música", "subtitleStyling": "Estilo de Legendas", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index aec7e05b..fb55cd94 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Тёмная", "oledTheme": "OLED", "libraryDensity": "Плотность библиотеки", + "displayScale": "", "compact": "Компактный", "comfortable": "Комфортный", "tvCornerSpotlightBackdrop": "Фоновое изображение в углу", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}МБ", "bufferSizeAuto": "Авто (Рекомендуется)", "bufferSizeWarning": "Доступно памяти: ${heap} МБ. Буфер размером ${size} МБ может повлиять на воспроизведение.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Качество по умолчанию", "musicQualityTitle": "Качество музыки", "subtitleStyling": "Стиль субтитров", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 82305407..a06147fc 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32738 (1488 per locale) +/// Strings: 32743 (1488 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 6a068ad2..8e0847f1 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -588,6 +588,21 @@ class Translations$settings$en { /// en: '${heap}MB memory available. A ${size}MB buffer may affect playback.' String bufferSizeWarning({required Object heap, required Object size}) => '${heap}MB memory available. A ${size}MB buffer may affect playback.'; + /// en: 'Playback Buffer' + String get playbackBuffer => 'Playback Buffer'; + + /// en: 'Auto (Recommended)' + String get playbackBufferAuto => 'Auto (Recommended)'; + + /// en: 'Large' + String get playbackBufferLarge => 'Large'; + + /// en: 'Extra Large' + String get playbackBufferExtraLarge => 'Extra Large'; + + /// en: 'Buffer more against unstable connections. Also limited by Buffer Size.' + String get playbackBufferDescription => 'Buffer more against unstable connections. Also limited by Buffer Size.'; + /// en: 'Default Quality' String get defaultQualityTitle => 'Default Quality'; @@ -6261,6 +6276,11 @@ extension on Translations { 'settings.bufferSizeMB' => ({required Object size}) => '${size}MB', 'settings.bufferSizeAuto' => 'Auto (Recommended)', 'settings.bufferSizeWarning' => ({required Object heap, required Object size}) => '${heap}MB memory available. A ${size}MB buffer may affect playback.', + 'settings.playbackBuffer' => 'Playback Buffer', + 'settings.playbackBufferAuto' => 'Auto (Recommended)', + 'settings.playbackBufferLarge' => 'Large', + 'settings.playbackBufferExtraLarge' => 'Extra Large', + 'settings.playbackBufferDescription' => 'Buffer more against unstable connections. Also limited by Buffer Size.', 'settings.defaultQualityTitle' => 'Default Quality', 'settings.musicQualityTitle' => 'Music Quality', 'settings.subtitleStyling' => 'Subtitle Styling', @@ -6610,13 +6630,13 @@ extension on Translations { 'mediaMenu.deleteMovieTitle' => 'Delete this movie?', 'mediaMenu.deleteEpisodeConfirm' => 'Delete episode', 'mediaMenu.deleteSeasonConfirm' => 'Delete season', + _ => null, + } ?? switch (path) { 'mediaMenu.deleteShowConfirm' => 'Delete show', 'mediaMenu.deleteMovieConfirm' => 'Delete movie', 'mediaMenu.deleteAnyway' => 'Delete anyway', 'mediaMenu.confirmDeleteTarget' => ({required Object title}) => 'Permanently delete ${title} from your server?', 'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.', - _ => null, - } ?? switch (path) { 'mediaMenu.deleteEpisodeCountWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This deletes all ${n} episode in it, and its file.', other: 'This deletes all ${n} episodes in it, and their files.', ), 'mediaMenu.deleteMultiPartWarning' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'This item is stored as ${n} file, which will be deleted.', other: 'This item is stored across ${n} files, and all of them will be deleted.', ), 'mediaMenu.deleteSharedFileHeading' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} other episode is stored in the same file and will be deleted too:', other: '${n} other episodes are stored in the same file and will be deleted too:', ), @@ -7124,13 +7144,13 @@ extension on Translations { 'explore.badge.pendingApproval' => 'Pending approval', 'explore.badge.processing' => 'Processing', 'explore.badge.declined' => 'Declined', + _ => null, + } ?? switch (path) { 'explore.badge.requestFailed' => 'Request failed', 'explore.badge.requested4k' => '4K requested', 'explore.badge.seasonsAvailable' => ({required Object available, required Object total}) => '${available}/${total} seasons', 'explore.badge.nextEpisodeIn' => ({required Object episode, required Object duration}) => 'Ep ${episode} in ${duration}', 'explore.badge.nextAiringIn' => ({required Object duration}) => 'Next in ${duration}', - _ => null, - } ?? switch (path) { 'explore.badge.episodesShort' => ({required Object n}) => '${n} eps', 'explore.badge.minutesPerEpisode' => ({required Object n}) => '${n} min/ep', 'explore.badge.adult' => '18+', @@ -7638,13 +7658,13 @@ extension on Translations { 'performanceOverlay.dvRpus' => 'DV RPUs', 'performanceOverlay.dvRpuAverage' => 'DV RPU Avg', 'performanceOverlay.dvSampleAverage' => 'DV Sample Avg', + _ => null, + } ?? switch (path) { 'performanceOverlay.maxLuma' => 'Max Luma', 'performanceOverlay.minLuma' => 'Min Luma', 'performanceOverlay.maxCll' => 'MaxCLL', 'performanceOverlay.maxFall' => 'MaxFALL', 'performanceOverlay.cacheUsed' => 'Cache Used', - _ => null, - } ?? switch (path) { 'performanceOverlay.cacheLimit' => 'Cache Limit', 'performanceOverlay.speed' => 'Speed', 'performanceOverlay.player' => 'Player', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 37ef5bb2..a795b361 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Mörkt", "oledTheme": "OLED", "libraryDensity": "Biblioteksdensitet", + "displayScale": "", "compact": "Kompakt", "comfortable": "Luftig", "tvCornerSpotlightBackdrop": "Bakgrundsbild för utvalt innehåll i hörnet", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Automatiskt (rekommenderas)", "bufferSizeWarning": "${heap} MB minne är tillgängligt. En buffert på ${size} MB kan påverka uppspelningen.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standardkvalitet", "musicQualityTitle": "Musikkvalitet", "subtitleStyling": "Utseende för undertexter", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 973dca61..ae238449 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Koyu", "oledTheme": "OLED", "libraryDensity": "Kitaplık Yoğunluğu", + "displayScale": "", "compact": "Sıkışık", "comfortable": "Rahat", "tvCornerSpotlightBackdrop": "Köşe Öne Çıkan Arka Plan", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "Otomatik (Önerilen)", "bufferSizeWarning": "${heap}MB bellek mevcut. ${size}MB arabellek oynatmayı etkileyebilir.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Varsayılan Kalite", "musicQualityTitle": "Müzik Kalitesi", "subtitleStyling": "Altyazı Biçimlendirmesi", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 0a526f94..31b8760f 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "Toʻq", "oledTheme": "OLED", "libraryDensity": "Kutubxona zichligi", + "displayScale": "", "compact": "Ixcham", "comfortable": "Qulay", "tvCornerSpotlightBackdrop": "Burchak yoritish foni", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "Avtomatik (Tavsiya etilgan)", "bufferSizeWarning": "${heap} MB Xotira mavjud. ${size} MB bufer ijroga taʼsir qilishi mumkin.", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "Standart sifat", "musicQualityTitle": "Musiqa sifati", "subtitleStyling": "Subtitr sozlamalari", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 7bd15d36..d373eff6 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "深色", "oledTheme": "OLED 純黑", "libraryDensity": "媒體庫版面配置密度", + "displayScale": "", "compact": "緊湊", "comfortable": "舒適", "tvCornerSpotlightBackdrop": "右上角焦點背景圖", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size} MB", "bufferSizeAuto": "自動(推薦)", "bufferSizeWarning": "可用記憶體為 ${heap} MB。設定 ${size} MB 緩衝可能影響播放穩定性。", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "預設畫質", "musicQualityTitle": "音樂品質", "subtitleStyling": "字幕樣式", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1042c32c..54a8ff90 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -120,6 +120,7 @@ "darkTheme": "深色", "oledTheme": "OLED", "libraryDensity": "媒体库密度", + "displayScale": "", "compact": "紧凑", "comfortable": "舒适", "tvCornerSpotlightBackdrop": "右上角聚焦背景图", @@ -169,6 +170,11 @@ "bufferSizeMB": "${size}MB", "bufferSizeAuto": "自动(推荐)", "bufferSizeWarning": "可用内存 ${heap}MB。${size}MB 缓冲可能影响播放。", + "playbackBuffer": "", + "playbackBufferAuto": "", + "playbackBufferLarge": "", + "playbackBufferExtraLarge": "", + "playbackBufferDescription": "", "defaultQualityTitle": "默认画质", "musicQualityTitle": "音乐音质", "subtitleStyling": "字幕样式", diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index c1535943..ecd1dc65 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -13,6 +13,7 @@ class PlayerAndroid extends PlayerBase { int? _bufferSizeBytes; bool _bufferSizeIsAuto = false; + String _bufferTier = 'auto'; bool _tunnelingEnabled = true; String _dvConversionMode = 'auto'; bool _audioNormalizationEnabled = false; @@ -115,6 +116,7 @@ class PlayerAndroid extends PlayerBase { final result = await invoke('initialize', { 'bufferSizeBytes': _bufferSizeBytes, 'bufferSizeAuto': _bufferSizeIsAuto, + 'bufferTier': _bufferTier, 'tunnelingEnabled': _tunnelingEnabled, 'dvConversionMode': _dvConversionMode, 'audioPassthroughEnabled': _audioPassthroughEnabled, @@ -331,6 +333,12 @@ class PlayerAndroid extends PlayerBase { case 'demuxer-max-bytes-auto': _bufferSizeIsAuto = value != 'no'; break; + // Not an mpv property. mpv read-ahead is owned by the mpv.conf editor; this tier is + // a named ExoPlayer read-ahead depth rather than a duration because the byte cap can + // bind first (#1816). + case 'exo-buffer-tier': + _bufferTier = value; + break; case 'tunneled-playback': _tunnelingEnabled = value != 'no'; break; diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index c8e7cce1..b7f59f47 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -91,6 +91,7 @@ class _PlaybackSettingsScreenState extends State { if (PlatformDetector.isAppleTV()) _atmosDiagnosticsTile(), if (exoActive) _dvConversionModeTile(), _bufferSizeTile(), + if (exoActive) _playbackBufferTile(), _defaultQualityTile(), _musicQualityTile(), ], @@ -433,6 +434,22 @@ class _PlaybackSettingsScreenState extends State { ); } + Widget _playbackBufferTile() => SettingSelectionTile( + pref: SettingsService.playbackBufferTier, + icon: Symbols.hourglass_top_rounded, + title: t.settings.playbackBuffer, + subtitleBuilder: (tier) => '${_playbackBufferLabel(tier)} · ${t.settings.playbackBufferDescription}', + options: PlaybackBufferTier.values + .map((tier) => DialogOption(value: tier, title: _playbackBufferLabel(tier))) + .toList(), + ); + + String _playbackBufferLabel(PlaybackBufferTier tier) => switch (tier) { + PlaybackBufferTier.auto => t.settings.playbackBufferAuto, + PlaybackBufferTier.large => t.settings.playbackBufferLarge, + PlaybackBufferTier.extraLarge => t.settings.playbackBufferExtraLarge, + }; + Widget _defaultQualityTile() => SettingSelectionTile( pref: SettingsService.defaultQualityPreset, icon: Symbols.high_quality_rounded, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 942a6a9b..31cc9268 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1193,6 +1193,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _exitFullscreenOnPlayerClose = settingsService.read(SettingsService.exitFullscreenOnPlayerClose); _rewindOnResume = settingsService.read(SettingsService.rewindOnResume); final bufferSizeMB = settingsService.read(SettingsService.bufferSize); + final playbackBufferTier = settingsService.read(SettingsService.playbackBufferTier); final enableHardwareDecoding = settingsService.read(SettingsService.enableHardwareDecoding); final debugLoggingEnabled = settingsService.read(SettingsService.enableDebugLogging); final useExoPlayer = settingsService.read(SettingsService.useExoPlayer); @@ -1285,6 +1286,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (Platform.isAndroid && useExoPlayer) { final tunneledPlayback = settingsService.read(SettingsService.tunneledPlayback); await currentPlayer.setProperty('tunneled-playback', tunneledPlayback ? 'yes' : 'no'); + await currentPlayer.setProperty('exo-buffer-tier', playbackBufferTier.nativeValue); } if ((Platform.isAndroid && useExoPlayer) || Platform.isIOS || Platform.isMacOS) { final dvConversionMode = settingsService.read(SettingsService.dvConversionMode); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 69118578..3feec81a 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -83,6 +83,16 @@ extension DvConversionModePreferenceNativeValue on DvConversionModePreference { }; } +enum PlaybackBufferTier { auto, large, extraLarge } + +extension PlaybackBufferTierNativeValue on PlaybackBufferTier { + String get nativeValue => switch (this) { + PlaybackBufferTier.auto => 'auto', + PlaybackBufferTier.large => 'large', + PlaybackBufferTier.extraLarge => 'extra_large', + }; +} + const String _bufferSizeMigratedKey = 'buffer_size_migrated_to_auto'; const String _legacyUseSeasonPosterKey = 'use_season_poster'; const String _legacyMpvConfigEntriesKey = 'mpv_config_entries'; @@ -504,6 +514,11 @@ class SettingsService extends BaseSharedPreferencesService { static const exitFullscreenOnPlayerClose = BoolPref('exit_fullscreen_on_player_close'); static const bufferSize = _BufferSizePref(); + static const playbackBufferTier = EnumPref( + 'playback_buffer_tier', + values: PlaybackBufferTier.values, + defaultValue: PlaybackBufferTier.auto, + ); static const libraryDensity = _LibraryDensityPref(); static const automotiveUiScale = _AutomotiveUiScalePref(); static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop'); @@ -932,6 +947,7 @@ class SettingsService extends BaseSharedPreferencesService { themeMode, videoPlayerNavigationEnabled, bufferSize, + playbackBufferTier, libraryDensity, automotiveUiScale, tvCornerSpotlightBackdrop, diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart index 5c215939..d2fb5b2c 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -104,6 +104,8 @@ class _PlayerPerformanceOverlayState extends State { _metric(t.fileInfo.duration, _stats.cacheDurationFormatted), if (isMpv) _metric(t.performanceOverlay.cacheUsed, _stats.cacheUsedFormatted), if (isMpv) _metric(t.performanceOverlay.cacheLimit, _stats.cacheLimitFormatted), + if (!isMpv && _stats.hasValidBufferLimits) + _metric(t.performanceOverlay.cacheLimit, _stats.bufferLimitsFormatted), if (isMpv) _metric(t.performanceOverlay.speed, _stats.cacheSpeedFormatted), ]), _buildSection(Symbols.apps_rounded, t.performanceOverlay.app, [ diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index 79bb51a1..bfddad77 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -53,6 +53,8 @@ class PerformanceStats { final int? cacheLimit; final double? cacheSpeed; final double? cacheDuration; + final int? bufferTargetBytes; + final int? bufferMaxMs; // DV conversion final bool dvConversionActive; @@ -106,6 +108,8 @@ class PerformanceStats { this.cacheLimit, this.cacheSpeed, this.cacheDuration, + this.bufferTargetBytes, + this.bufferMaxMs, this.dvConversionActive = false, this.dvConversionMode = '', this.dvConvertedRpus, @@ -157,6 +161,8 @@ class PerformanceStats { cacheLimit = null, cacheSpeed = null, cacheDuration = null, + bufferTargetBytes = null, + bufferMaxMs = null, dvConversionActive = false, dvConversionMode = '', dvConvertedRpus = null, @@ -183,6 +189,17 @@ class PerformanceStats { return '${mbps.toStringAsFixed(1)} Mbps'; } + /// Both ExoPlayer read-ahead ceilings: the duration target and the hard byte cap. + /// The smaller one binds, so showing both explains a duration setting that + /// appears to have no effect on high-bitrate media. + String get bufferLimitsFormatted { + if (bufferMaxMs == null || bufferMaxMs! <= 0) return 'N/A'; + final duration = '${bufferMaxMs! ~/ 1000}s'; + if (bufferTargetBytes == null) return duration; + final targetBufferMb = bufferTargetBytes! ~/ (1024 * 1024); + return '$duration / ${targetBufferMb}MB'; + } + /// Format audio bitrate in kbps. String get audioBitrateFormatted { if (audioBitrate == null || audioBitrate == 0) return 'N/A'; @@ -377,6 +394,8 @@ class PerformanceStats { return videoBitrate != null && videoBitrate! > 0; } + bool get hasValidBufferLimits => bufferMaxMs != null && bufferMaxMs! > 0; + /// Check if audio bitrate is valid (not null, not negative, not zero). bool get hasValidAudioBitrate { return audioBitrate != null && audioBitrate! > 0; diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 3a21478b..530c1035 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -211,6 +211,8 @@ class PerformanceStatsService { frameDropCount: statsMap['videoDroppedFrames'] as int?, // Buffer metrics - convert ms to seconds for duration cacheDuration: ((statsMap['totalBufferedDurationMs'] as int?) ?? 0) / 1000.0, + bufferTargetBytes: statsMap['bufferTargetBytes'] as int?, + bufferMaxMs: statsMap['bufferMaxMs'] as int?, // DV conversion dvConversionActive: statsMap['dvConversionActive'] == true, dvConversionMode: statsMap['dvConversionMode'] as String? ?? '', diff --git a/test/mpv/player_android_buffer_size_test.dart b/test/mpv/player_android_buffer_size_test.dart index d60486e9..8c56daaa 100644 --- a/test/mpv/player_android_buffer_size_test.dart +++ b/test/mpv/player_android_buffer_size_test.dart @@ -82,5 +82,20 @@ void main() { final args = initialize.arguments as Map; expect(args['bufferSizeAuto'], isFalse); expect(args['bufferSizeBytes'], isNull); + // Playback Buffer is also init-only: when absent, native must receive an explicit Auto wire value. + expect(args['bufferTier'], 'auto'); }); + + test('an explicit Playback Buffer tier reaches native initialization', () async { + final initialize = await _captureInitialize( + configure: (player) async { + await player.setProperty('exo-buffer-tier', PlaybackBufferTier.extraLarge.nativeValue); + }, + ); + + final args = initialize.arguments as Map; + // The synthetic property is init-only; losing it would silently restore the native Auto tier. + expect(args['bufferTier'], 'extra_large'); + }); + }