fix(android): stop tunneling 24p video on the Fire TV Stick 4K

Tunneled playback on an AFTMM judders continuously through 23.976p direct play.
The #1802 reporter isolated it: turning off Tunneled Playback with every other
setting unchanged makes it smooth, and their log shows tunneling active for the
whole session with E-AC3 bitstreamed and the decoded-PCM guard never firing.

Audio Passthrough looked like the trigger only because it is the one user-facing
switch that decides it. Passthrough off, or Downmix to Stereo on, both force the
Dolby track to decode to PCM, which trips the #1458 guard and takes tunneling
down with it. Passthrough on with downmix off is the only combination that keeps
a bitstreamed track, so it is the only one that stays tunneled.

Withdraw tunneling on that model for content at or below 30fps. The cut-off
keeps 4K50/60 tunneled, which is the workload Amazon documents the feature for.
The mechanism stays unconfirmed: tunneling fires no VideoFrameMetadataListener
and stops media3 counting frames in the codec, so nothing app-side can measure
the cadence. Only the trigger is established, and the quirk is scoped to it.

That needs a frame rate the app did not have. Neither MatroskaExtractor nor
Mp4Extractor populates Format.frameRate, and a tunneled session renders no
frames back for the native detector, so the server's rate now rides on the open
call. It is sent only for direct play, matching _primeDisplayCriteria: a
transcode's metadata describes the source, not what the server is about to send.

Also move Audio Passthrough out of the in-player settings sheet. It configures
the audio output route rather than the current playback, and applying it
mid-stream bounces the audio renderer and re-decides tunneling. Settings > Video
Playback already owns it, next to Tunneled Playback, which is applied the same
way. That description now mentions stutter, not only black HDR video, so the
workaround is findable on hardware this quirk does not cover.

The mpv backend failing to start the same 4K file is a separate defect and is
not addressed here; its uploaded log is no longer retrievable.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent 1b6a811c07
commit f93952ba6f
56 changed files with 263 additions and 88 deletions
@@ -322,6 +322,12 @@ class ExoPlayerCore(private val activity: Activity) :
@Volatile private var detectedFrameRate: Float = -1f
private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)
// Frame rate the media server reported for the open item, or -1 when unknown.
// Supplied per open because the extractors media3 uses for direct play (Matroska,
// MP4) never populate Format.frameRate, and the tunneled path renders no frames
// back to the app for detectedFrameRate to derive one from.
@Volatile private var contentFrameRate: Float = -1f
@Volatile private var fpsTimestampCount = 0
private var assSyncFrameCount = 0L
@@ -2590,6 +2596,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return null
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
return tunnelingUserEnabled &&
!DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate) &&
(player.playbackParameters.speed == 1f) &&
!tunnelingDisabledForCodec &&
!tunnelingDisabledForAssSubtitles &&
@@ -3270,7 +3277,8 @@ class ExoPlayerCore(private val activity: Activity) :
autoPlay: Boolean,
mediaGeneration: Int,
isLive: Boolean = false,
externalSubtitleList: List<Map<String, Any?>>? = null
externalSubtitleList: List<Map<String, Any?>>? = null,
contentFrameRate: Float = -1f
) {
if (!isInitialized) return
@@ -3283,6 +3291,7 @@ class ExoPlayerCore(private val activity: Activity) :
// Reset FPS detection for new content
detectedFrameRate = -1f
this.contentFrameRate = contentFrameRate
fpsTimestampCount = 0
assSyncFrameCount = 0
@@ -3400,7 +3409,14 @@ class ExoPlayerCore(private val activity: Activity) :
}
val sourceLabel = if (isLive) "live HLS" else "media"
emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
emitLog(
"info",
"media",
"Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, " +
"contentFps=${if (contentFrameRate > 0f) contentFrameRate.toString() else "unknown"}, " +
"sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled, " +
"tunnelingStatus=${exoPlayer?.let(::getTunnelingStatus) ?: "n/a"}"
)
}
fun setAudioDelay(seconds: Double) {
@@ -4114,6 +4130,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun getTunnelingStatus(player: ExoPlayer): String {
if (currentTunneledPlayback) return "Active"
if (!tunnelingUserEnabled) return "Disabled by user"
if (DeviceQuirks.hasUnreliableTunneledPlayback(contentFrameRate)) return "Off (unreliable on this device)"
if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
if (audioNormalizationEnabled) return "Off (loudness normalization)"
if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
@@ -64,6 +64,7 @@ class ExoPlayerPlugin :
val autoPlay: Boolean,
val isLive: Boolean,
val externalSubtitles: List<Map<String, Any?>>?,
val contentFrameRate: Float,
private val result: MethodChannel.Result?
) {
private val completed = AtomicBoolean(false)
@@ -379,6 +380,8 @@ class ExoPlayerPlugin :
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
val isLive = call.argument<Boolean>("isLive") ?: false
val externalSubtitles = call.argument<List<Map<String, Any?>>>("externalSubtitles")
// Server-reported frame rate for this item; -1 when the metadata did not carry one.
val contentFrameRate = call.argument<Number>("contentFrameRate")?.toFloat() ?: -1f
if (uri == null) {
result.error("INVALID_ARGS", "Missing 'uri'", null)
@@ -399,6 +402,7 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
isLive = isLive,
externalSubtitles = externalSubtitles?.map { it.toMap() },
contentFrameRate = contentFrameRate,
result = result
)
terminalEventGeneration = null
@@ -458,7 +462,8 @@ class ExoPlayerPlugin :
autoPlay = autoPlay,
mediaGeneration = request.mediaGeneration,
isLive = isLive,
externalSubtitleList = request.externalSubtitles
externalSubtitleList = request.externalSubtitles,
contentFrameRate = request.contentFrameRate
)
request.success()
}
@@ -1394,6 +1399,8 @@ class ExoPlayerPlugin :
autoPlay = playWhenReady,
isLive = false,
externalSubtitles = currentExternalSubtitles?.map { it.toMap() },
// The mpv fallback core paces frames itself; the rate only gates ExoPlayer tunneling.
contentFrameRate = -1f,
result = null
)
fallbackInProgress = true
@@ -6,6 +6,43 @@ object DeviceQuirks {
val isEWaste: Boolean
get() = isGooglePixelDevice || isGoogleTensorDevice
/**
* Highest content frame rate for which [hasUnreliableTunneledPlayback] applies.
*
* Tunneling exists to meet the scheduling deadlines of high-frame-rate 4K, which Amazon
* documents and recommends for exactly that workload, so the cut-off keeps 4K50/60
* tunneled. Withdrawing tunneling is not free even below it — the device pipeline saves
* CPU and power at any rate — but ~33ms per frame is well inside what media3's own
* release path handles, and a juddering picture is the worse trade.
*/
const val MAX_UNRELIABLE_TUNNELED_FPS = 30f
/**
* Fire TV Stick 4K (AFTMM) judders continuously on 23.976p direct play while tunneled.
* The #1802 reporter A/B-tested it: tunneling off, every other setting unchanged, and
* the judder goes away. Untunneled, media3's VideoFrameReleaseHelper owns frame release
* and vsync-aligns it; tunneled, the device owns it and media3 notes it "may do frame
* rate conversion".
*
* The mechanism is unconfirmed. Tunneling fires no VideoFrameMetadataListener and stops
* media3 counting frames in the codec, so nothing app-side can measure the cadence or
* drops. Only the trigger is established.
*
* Deliberately scoped to the one model and the one rate class that were tested: AFTMM
* keeps tunneling for high-frame-rate content, and an unknown rate changes nothing.
* Widen only on further reports.
*/
fun hasUnreliableTunneledPlayback(contentFrameRate: Float): Boolean = hasUnreliableTunneledPlayback(Build.MANUFACTURER, Build.MODEL, contentFrameRate)
internal fun hasUnreliableTunneledPlayback(
manufacturer: String,
model: String,
contentFrameRate: Float
): Boolean = manufacturer.equals("Amazon", ignoreCase = true) &&
model.equals("AFTMM", ignoreCase = true) &&
contentFrameRate > 0f &&
contentFrameRate <= MAX_UNRELIABLE_TUNNELED_FPS
private val isGooglePixelDevice: Boolean
get() = (
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
@@ -0,0 +1,58 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceQuirksTest {
private fun unreliable(
manufacturer: String = "Amazon",
model: String = "AFTMM",
fps: Float = 23.976f
) = DeviceQuirks.hasUnreliableTunneledPlayback(manufacturer, model, fps)
@Test
fun fireTvStick4kJuddersOnFilmRateSoTunnelingIsWithdrawn() {
assertTrue(unreliable(fps = 23.976f))
assertTrue(unreliable(fps = 24f))
assertTrue(unreliable(fps = 25f))
assertTrue(unreliable(fps = 29.97f))
assertTrue(unreliable(fps = DeviceQuirks.MAX_UNRELIABLE_TUNNELED_FPS))
}
@Test
fun highFrameRateKeepsTunnelingOnTheSameDevice() {
// Tunneling exists for this workload and Amazon recommends it here, so the
// quirk must not reach 4K50/60 content.
assertFalse(unreliable(fps = 50f))
assertFalse(unreliable(fps = 59.94f))
assertFalse(unreliable(fps = 60f))
}
@Test
fun unknownFrameRateLeavesBehaviourUnchanged() {
assertFalse(unreliable(fps = -1f))
assertFalse(unreliable(fps = 0f))
}
@Test
fun otherAmazonHardwareIsUnaffected() {
// Only AFTMM was reported and A/B-tested; siblings keep stock behaviour.
assertFalse(unreliable(model = "AFTKA"))
assertFalse(unreliable(model = "AFTMM2"))
assertFalse(unreliable(model = "AFTSSS"))
assertFalse(unreliable(model = "KFMAWI"))
}
@Test
fun otherManufacturersAreUnaffected() {
assertFalse(unreliable(manufacturer = "NVIDIA", model = "SHIELD Android TV"))
assertFalse(unreliable(manufacturer = "Sony", model = "BRAVIA 4K GB"))
}
@Test
fun buildStringCasingDoesNotDefeatTheMatch() {
assertTrue(unreliable(manufacturer = "amazon", model = "aftmm"))
}
}
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Səs çıxışı",
"performanceOverlay": "Məhsuldarlıq paneli",
"audioPassthrough": "Səsin birbaşa ötürülməsi",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Əhatəli səs",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Аудио изход",
"performanceOverlay": "Оверлей за производителност",
"audioPassthrough": "Директно предаване на аудио",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Съраунд",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Lydoutput",
"performanceOverlay": "Ydelsesoverlay",
"audioPassthrough": "Lyd-passthrough",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige",
"audioPassthrough": "Audio-Durchleitung",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
+1 -2
View File
@@ -282,7 +282,7 @@
"matchDynamicRangeDescription": "Switch HDR on for HDR content, then back to SDR",
"displaySwitchDelay": "Display Switch Delay",
"tunneledPlayback": "Tunneled Playback",
"tunneledPlaybackDescription": "Use video tunneling. Disable if HDR playback shows black video.",
"tunneledPlaybackDescription": "Use video tunneling. Disable if HDR playback shows black video or motion stutters.",
"audioPassthrough": "Audio Passthrough",
"audioPassthroughDescription": "Send Dolby/DTS audio to your receiver or TV without re-encoding, preserving surround sound. Turn off if you have no sound.",
"audioPassthroughDescriptionAppleTv": "Use Apple's native Dolby decoder for Dolby Digital Plus, including Atmos. DTS and TrueHD still play as multichannel PCM. Turn off if you have no sound.",
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay",
"audioPassthrough": "Audio Passthrough",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento",
"audioPassthrough": "Transferencia directa de audio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Envolvente",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Sortie audio",
"performanceOverlay": "Données de performance",
"audioPassthrough": "Transmission audio directe",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Hangkimenet",
"performanceOverlay": "Teljesítményadatok",
"audioPassthrough": "Hangtovábbítás (passthrough)",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Térhatású",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni",
"audioPassthrough": "Passthrough audio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1641,7 +1641,6 @@
"hdr": "HDR",
"audioOutput": "音声出力",
"performanceOverlay": "パフォーマンスオーバーレイ",
"audioPassthrough": "オーディオパススルー",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "サラウンド",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Аудио шығысы",
"performanceOverlay": "Өнімділік панелі",
"audioPassthrough": "Дыбысты тікелей өткізу",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Көлемді дыбыс",
-1
View File
@@ -1641,7 +1641,6 @@
"hdr": "HDR",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이",
"audioPassthrough": "오디오 패스스루",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "서라운드",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Lydutgang",
"performanceOverlay": "Ytelsesoverlegg",
"audioPassthrough": "Direkte lydutgang",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay",
"audioPassthrough": "Audio-doorvoer",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1668,7 +1668,6 @@
"hdr": "HDR",
"audioOutput": "Wyjście audio",
"performanceOverlay": "Nakładka wydajności",
"audioPassthrough": "Przekazywanie dźwięku",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Przestrzenny",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Saída de áudio",
"performanceOverlay": "Painel de desempenho",
"audioPassthrough": "Passagem direta de áudio",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1668,7 +1668,6 @@
"hdr": "HDR",
"audioOutput": "Аудиовыход",
"performanceOverlay": "Оверлей производительности",
"audioPassthrough": "Сквозной вывод аудио",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Объёмный звук",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 32759 (1489 per locale)
/// Strings: 32737 (1488 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
-2
View File
@@ -1528,7 +1528,6 @@ class _Translations$videoSettings$az extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Səs çıxışı';
@override String get performanceOverlay => 'Məhsuldarlıq paneli';
@override String get audioPassthrough => 'Səsin birbaşa ötürülməsi';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Əhatəli səs';
@@ -3440,7 +3439,6 @@ extension on TranslationsAz {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Səs çıxışı',
'videoSettings.performanceOverlay' => 'Məhsuldarlıq paneli',
'videoSettings.audioPassthrough' => 'Səsin birbaşa ötürülməsi',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Əhatəli səs',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$bg extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Аудио изход';
@override String get performanceOverlay => 'Оверлей за производителност';
@override String get audioPassthrough => 'Директно предаване на аудио';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Съраунд';
@@ -3418,7 +3417,6 @@ extension on TranslationsBg {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Аудио изход',
'videoSettings.performanceOverlay' => 'Оверлей за производителност',
'videoSettings.audioPassthrough' => 'Директно предаване на аудио',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Съраунд',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$da extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Lydoutput';
@override String get performanceOverlay => 'Ydelsesoverlay';
@override String get audioPassthrough => 'Lyd-passthrough';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsDa {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Lydoutput',
'videoSettings.performanceOverlay' => 'Ydelsesoverlay',
'videoSettings.audioPassthrough' => 'Lyd-passthrough',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$de extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audioausgabe';
@override String get performanceOverlay => 'Leistungsanzeige';
@override String get audioPassthrough => 'Audio-Durchleitung';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsDe {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audioausgabe',
'videoSettings.performanceOverlay' => 'Leistungsanzeige',
'videoSettings.audioPassthrough' => 'Audio-Durchleitung',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
+4 -8
View File
@@ -924,8 +924,8 @@ class Translations$settings$en {
/// en: 'Tunneled Playback'
String get tunneledPlayback => 'Tunneled Playback';
/// en: 'Use video tunneling. Disable if HDR playback shows black video.'
String get tunneledPlaybackDescription => 'Use video tunneling. Disable if HDR playback shows black video.';
/// en: 'Use video tunneling. Disable if HDR playback shows black video or motion stutters.'
String get tunneledPlaybackDescription => 'Use video tunneling. Disable if HDR playback shows black video or motion stutters.';
/// en: 'Audio Passthrough'
String get audioPassthrough => 'Audio Passthrough';
@@ -4133,9 +4133,6 @@ class Translations$videoSettings$en {
/// en: 'Performance Overlay'
String get performanceOverlay => 'Performance Overlay';
/// en: 'Audio Passthrough'
String get audioPassthrough => 'Audio Passthrough';
/// en: 'Dolby Atmos'
String get audioOutputDolbyAtmos => 'Dolby Atmos';
@@ -6373,7 +6370,7 @@ extension on Translations {
'settings.matchDynamicRangeDescription' => 'Switch HDR on for HDR content, then back to SDR',
'settings.displaySwitchDelay' => 'Display Switch Delay',
'settings.tunneledPlayback' => 'Tunneled Playback',
'settings.tunneledPlaybackDescription' => 'Use video tunneling. Disable if HDR playback shows black video.',
'settings.tunneledPlaybackDescription' => 'Use video tunneling. Disable if HDR playback shows black video or motion stutters.',
'settings.audioPassthrough' => 'Audio Passthrough',
'settings.audioPassthroughDescription' => 'Send Dolby/DTS audio to your receiver or TV without re-encoding, preserving surround sound. Turn off if you have no sound.',
'settings.audioPassthroughDescriptionAppleTv' => 'Use Apple\'s native Dolby decoder for Dolby Digital Plus, including Atmos. DTS and TrueHD still play as multichannel PCM. Turn off if you have no sound.',
@@ -7605,7 +7602,6 @@ extension on Translations {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
@@ -7643,9 +7639,9 @@ extension on Translations {
'performanceOverlay.maxCll' => 'MaxCLL',
'performanceOverlay.maxFall' => 'MaxFALL',
'performanceOverlay.cacheUsed' => 'Cache Used',
'performanceOverlay.cacheLimit' => 'Cache Limit',
_ => null,
} ?? switch (path) {
'performanceOverlay.cacheLimit' => 'Cache Limit',
'performanceOverlay.speed' => 'Speed',
'performanceOverlay.player' => 'Player',
'performanceOverlay.memory' => 'Memory',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$es extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Salida de audio';
@override String get performanceOverlay => 'Indicador de rendimiento';
@override String get audioPassthrough => 'Transferencia directa de audio';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Envolvente';
@@ -3418,7 +3417,6 @@ extension on TranslationsEs {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Salida de audio',
'videoSettings.performanceOverlay' => 'Indicador de rendimiento',
'videoSettings.audioPassthrough' => 'Transferencia directa de audio',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Envolvente',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$fr extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Sortie audio';
@override String get performanceOverlay => 'Données de performance';
@override String get audioPassthrough => 'Transmission audio directe';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsFr {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Sortie audio',
'videoSettings.performanceOverlay' => 'Données de performance',
'videoSettings.audioPassthrough' => 'Transmission audio directe',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$hu extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Hangkimenet';
@override String get performanceOverlay => 'Teljesítményadatok';
@override String get audioPassthrough => 'Hangtovábbítás (passthrough)';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Térhatású';
@@ -3418,7 +3417,6 @@ extension on TranslationsHu {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Hangkimenet',
'videoSettings.performanceOverlay' => 'Teljesítményadatok',
'videoSettings.audioPassthrough' => 'Hangtovábbítás (passthrough)',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Térhatású',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$it extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Uscita audio';
@override String get performanceOverlay => 'Overlay prestazioni';
@override String get audioPassthrough => 'Passthrough audio';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsIt {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Uscita audio',
'videoSettings.performanceOverlay' => 'Overlay prestazioni',
'videoSettings.audioPassthrough' => 'Passthrough audio',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1514,7 +1514,6 @@ class _Translations$videoSettings$ja extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => '音声出力';
@override String get performanceOverlay => 'パフォーマンスオーバーレイ';
@override String get audioPassthrough => 'オーディオパススルー';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'サラウンド';
@@ -3415,7 +3414,6 @@ extension on TranslationsJa {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音声出力',
'videoSettings.performanceOverlay' => 'パフォーマンスオーバーレイ',
'videoSettings.audioPassthrough' => 'オーディオパススルー',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'サラウンド',
-2
View File
@@ -1528,7 +1528,6 @@ class _Translations$videoSettings$kk extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Аудио шығысы';
@override String get performanceOverlay => 'Өнімділік панелі';
@override String get audioPassthrough => 'Дыбысты тікелей өткізу';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Көлемді дыбыс';
@@ -3440,7 +3439,6 @@ extension on TranslationsKk {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Аудио шығысы',
'videoSettings.performanceOverlay' => 'Өнімділік панелі',
'videoSettings.audioPassthrough' => 'Дыбысты тікелей өткізу',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Көлемді дыбыс',
-2
View File
@@ -1514,7 +1514,6 @@ class _Translations$videoSettings$ko extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => '오디오 출력';
@override String get performanceOverlay => '성능 오버레이';
@override String get audioPassthrough => '오디오 패스스루';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => '서라운드';
@@ -3415,7 +3414,6 @@ extension on TranslationsKo {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '오디오 출력',
'videoSettings.performanceOverlay' => '성능 오버레이',
'videoSettings.audioPassthrough' => '오디오 패스스루',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => '서라운드',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$nb extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Lydutgang';
@override String get performanceOverlay => 'Ytelsesoverlegg';
@override String get audioPassthrough => 'Direkte lydutgang';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsNb {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Lydutgang',
'videoSettings.performanceOverlay' => 'Ytelsesoverlegg',
'videoSettings.audioPassthrough' => 'Direkte lydutgang',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$nl extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audio-uitvoer';
@override String get performanceOverlay => 'Prestatie-overlay';
@override String get audioPassthrough => 'Audio-doorvoer';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsNl {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio-uitvoer',
'videoSettings.performanceOverlay' => 'Prestatie-overlay',
'videoSettings.audioPassthrough' => 'Audio-doorvoer',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1523,7 +1523,6 @@ class _Translations$videoSettings$pl extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Wyjście audio';
@override String get performanceOverlay => 'Nakładka wydajności';
@override String get audioPassthrough => 'Przekazywanie dźwięku';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Przestrzenny';
@@ -3424,7 +3423,6 @@ extension on TranslationsPl {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Wyjście audio',
'videoSettings.performanceOverlay' => 'Nakładka wydajności',
'videoSettings.audioPassthrough' => 'Przekazywanie dźwięku',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Przestrzenny',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$pt extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Saída de áudio';
@override String get performanceOverlay => 'Painel de desempenho';
@override String get audioPassthrough => 'Passagem direta de áudio';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsPt {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Saída de áudio',
'videoSettings.performanceOverlay' => 'Painel de desempenho',
'videoSettings.audioPassthrough' => 'Passagem direta de áudio',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1523,7 +1523,6 @@ class _Translations$videoSettings$ru extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Аудиовыход';
@override String get performanceOverlay => 'Оверлей производительности';
@override String get audioPassthrough => 'Сквозной вывод аудио';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Объёмный звук';
@@ -3424,7 +3423,6 @@ extension on TranslationsRu {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Аудиовыход',
'videoSettings.performanceOverlay' => 'Оверлей производительности',
'videoSettings.audioPassthrough' => 'Сквозной вывод аудио',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Объёмный звук',
-2
View File
@@ -1517,7 +1517,6 @@ class _Translations$videoSettings$sv extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Ljudutgång';
@override String get performanceOverlay => 'Prestandaöverlägg';
@override String get audioPassthrough => 'Ljudgenomströmning';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3418,7 +3417,6 @@ extension on TranslationsSv {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Ljudutgång',
'videoSettings.performanceOverlay' => 'Prestandaöverlägg',
'videoSettings.audioPassthrough' => 'Ljudgenomströmning',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1528,7 +1528,6 @@ class _Translations$videoSettings$tr extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Ses Çıkışı';
@override String get performanceOverlay => 'Performans Katmanı';
@override String get audioPassthrough => 'Ses Doğrudan Geçişi';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Çevreleyen Ses';
@@ -3440,7 +3439,6 @@ extension on TranslationsTr {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Ses Çıkışı',
'videoSettings.performanceOverlay' => 'Performans Katmanı',
'videoSettings.audioPassthrough' => 'Ses Doğrudan Geçişi',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Çevreleyen Ses',
-2
View File
@@ -1528,7 +1528,6 @@ class _Translations$videoSettings$uz extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audio chiqishi';
@override String get performanceOverlay => 'Unumdorlik paneli';
@override String get audioPassthrough => 'Ovozni toʻgʻridan-toʻgʻri oʻtkazish';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => 'Surround';
@@ -3440,7 +3439,6 @@ extension on TranslationsUz {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio chiqishi',
'videoSettings.performanceOverlay' => 'Unumdorlik paneli',
'videoSettings.audioPassthrough' => 'Ovozni toʻgʻridan-toʻgʻri oʻtkazish',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => 'Surround',
-2
View File
@@ -1514,7 +1514,6 @@ class Translations$videoSettings$zh extends Translations$videoSettings$en {
@override String get hdr => 'HDR';
@override String get audioOutput => '音频输出';
@override String get performanceOverlay => '性能监控';
@override String get audioPassthrough => '音频直通';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => '环绕声';
@@ -3415,7 +3414,6 @@ extension on TranslationsZh {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音频输出',
'videoSettings.performanceOverlay' => '性能监控',
'videoSettings.audioPassthrough' => '音频直通',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => '环绕声',
-2
View File
@@ -1515,7 +1515,6 @@ class _Translations$videoSettings$zh_Hant extends Translations$videoSettings$zh
@override String get hdr => 'HDR';
@override String get audioOutput => '音訊輸出';
@override String get performanceOverlay => '效能監控';
@override String get audioPassthrough => '音訊直通';
@override String get audioOutputDolbyAtmos => 'Dolby Atmos';
@override String get audioOutputDolbyAudio => 'Dolby Audio';
@override String get audioOutputSurround => '環繞聲';
@@ -3416,7 +3415,6 @@ extension on TranslationsZhHant {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音訊輸出',
'videoSettings.performanceOverlay' => '效能監控',
'videoSettings.audioPassthrough' => '音訊直通',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
'videoSettings.audioOutputDolbyAudio' => 'Dolby Audio',
'videoSettings.audioOutputSurround' => '環繞聲',
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg",
"audioPassthrough": "Ljudgenomströmning",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Ses Çıkışı",
"performanceOverlay": "Performans Katmanı",
"audioPassthrough": "Ses Doğrudan Geçişi",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Çevreleyen Ses",
-1
View File
@@ -1650,7 +1650,6 @@
"hdr": "HDR",
"audioOutput": "Audio chiqishi",
"performanceOverlay": "Unumdorlik paneli",
"audioPassthrough": "Ovozni toʻgʻridan-toʻgʻri oʻtkazish",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "Surround",
-1
View File
@@ -1641,7 +1641,6 @@
"hdr": "HDR",
"audioOutput": "音訊輸出",
"performanceOverlay": "效能監控",
"audioPassthrough": "音訊直通",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "環繞聲",
-1
View File
@@ -1641,7 +1641,6 @@
"hdr": "HDR",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控",
"audioPassthrough": "音频直通",
"audioOutputDolbyAtmos": "Dolby Atmos",
"audioOutputDolbyAudio": "Dolby Audio",
"audioOutputSurround": "环绕声",
@@ -21,6 +21,12 @@ class PlayerAndroid extends PlayerBase {
int _downmixCenterBoostDb = 0;
bool _downmixNormalize = true;
/// Server-reported frame rate for the next item, or null when unknown.
///
/// Rides on `open` rather than a standalone call because it is per-item and
/// must be known before the native side settles tunneling for that item.
double? _contentFrameRate;
/// The native plugin switched from ExoPlayer to its mpv fallback for this
/// session. Sticky for the instance lifetime, mirroring the native flag
/// (which resets only on initialize/dispose).
@@ -200,6 +206,7 @@ class PlayerAndroid extends PlayerBase {
'hasStartPosition': hasStartPosition,
'autoPlay': play,
'isLive': isLive,
if (_contentFrameRate != null) 'contentFrameRate': _contentFrameRate,
if (externalSubtitles != null && externalSubtitles.isNotEmpty)
'externalSubtitles': externalSubtitles
.where((s) => s.uri?.isNotEmpty == true)
@@ -327,6 +334,10 @@ class PlayerAndroid extends PlayerBase {
case 'tunneled-playback':
_tunnelingEnabled = value != 'no';
break;
case 'content-frame-rate':
final fps = double.tryParse(value);
_contentFrameRate = fps != null && fps > 0 ? fps : null;
break;
case 'dv-conversion-mode':
_dvConversionMode = value;
await _applyWhenInitialized(
@@ -710,6 +710,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: true,
isTranscoding: result.isTranscoding,
ensureAudioFocus: () => currentPlayer.requestAudioFocus(),
);
if (frameRatePlan == null || !isCurrentReload()) {
@@ -182,6 +182,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
required SettingsService settingsService,
required double? preKnownFps,
required bool hasVideoUrl,
required bool isTranscoding,
required Future<void> Function() ensureAudioFocus,
int preKnownWidth = 0,
int preKnownHeight = 0,
@@ -192,6 +193,20 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
// the two Android backends: mpv needs its decoder refreshed after a
// display switch (pre-load path), ExoPlayer switches pre-open instead.
final isAndroidMpv = currentPlayer.needsDecoderRefreshAfterDisplaySwitch;
// Independent of matchContentFrameRate: ExoPlayer needs the rate even when the
// display never switches, because it also decides whether video tunneling is
// safe for this item. Neither the Matroska nor the MP4 extractor populates
// Format.frameRate, and a tunneled session renders no frames back for the
// native FPS detector, so metadata is the only source.
//
// Source-side only, like _primeDisplayCriteria: a transcode's metadata rate
// describes the original file, not what the server is about to send. "0" clears
// a stale rate carried over from the previous item.
if (Platform.isAndroid && !isAndroidMpv) {
final directPlayFps = isTranscoding ? null : preKnownFps;
await currentPlayer.setProperty('content-frame-rate', (directPlayFps ?? 0).toString());
}
final needsMpvPreLoad = willAutoSwitch && isAndroidMpv && hasVideoUrl;
final needsExoPreOpen = willAutoSwitch && !isAndroidMpv && hasVideoUrl;
plan.needsPostOpenSwitch = willAutoSwitch && !needsMpvPreLoad && !needsExoPreOpen;
@@ -198,6 +198,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
preKnownWidth: displayCriteria?.width ?? 0,
preKnownHeight: displayCriteria?.height ?? 0,
hasVideoUrl: result.videoUrl != null,
isTranscoding: result.isTranscoding,
ensureAudioFocus: ensureAudioFocus,
);
if (frameRatePlan == null) return;
@@ -632,14 +632,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
},
),
// Audio Passthrough (desktop, Android TV, and Apple TV)
if (PlatformDetector.supportsAudioPassthrough())
_SettingsToggleItem(
pref: SettingsService.audioPassthrough,
icon: Symbols.surround_sound_rounded,
title: t.videoSettings.audioPassthrough,
onAfterWrite: widget.player.setAudioPassthrough,
),
// Audio Passthrough is not here: it configures the audio output route rather
// than this playback, and applying it mid-stream bounces the audio renderer and
// re-decides video tunneling. It lives in Settings > Video Playback next to
// Tunneled Playback, which is applied the same way — at the next player start.
// Dolby playback badge. The Dolby application guide requires the app
// to reflect AVAudioSession.renderingMode; Apple only resolves that
@@ -0,0 +1,93 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.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 content frame-rate contract between Dart and `ExoPlayerPlugin` (#1802).
///
/// The native side gates video tunneling on this rate: a Fire TV Stick 4K judders through
/// tunneled 23.976p, so `DeviceQuirks.hasUnreliableTunneledPlayback` withdraws tunneling
/// below 30fps and leaves the 4K60 workload tunneling alone. Neither the Matroska nor the
/// MP4 extractor populates `Format.frameRate`, and a tunneled session renders no frames
/// back for the native FPS detector, so this channel argument is the only source.
///
/// Losing it is silent — the native default is "unknown", which keeps stock behaviour and
/// simply never applies the fix.
Future<MethodCall> _captureOpen({required Future<void> Function(PlayerAndroid player) configure}) async {
late MethodCall open;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/exo_player',
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) async {
if (call.method == 'open') open = call;
return call.method == 'initialize' ? true : null;
},
testBody: () async {
final player = PlayerAndroid();
try {
await configure(player);
await player.open(const Media('https://example.test/a.mkv'), play: false);
} finally {
await player.dispose();
}
},
);
return open;
}
Map<Object?, Object?> _args(MethodCall call) => call.arguments as Map<Object?, Object?>;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('the metadata frame rate reaches the native open call', () async {
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '23.976'));
expect(_args(open)['contentFrameRate'], closeTo(23.976, 1e-6));
});
test('a high frame rate is forwarded unchanged so tunneling survives', () async {
// The quirk must be able to tell 4K60 apart from 24p; clamping or rounding here
// would withdraw tunneling from the workload it exists for.
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '59.94'));
expect(_args(open)['contentFrameRate'], closeTo(59.94, 1e-6));
});
test('unknown metadata sends no rate rather than a bogus one', () async {
// video_player_screen writes "0" when the server gave no frame rate. Forwarding 0 as a
// real value would be indistinguishable from a measured rate on the native side.
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', '0'));
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
test('an item without a rate clears the previous item\'s rate', () async {
// Episode-to-episode reuse keeps the same PlayerAndroid, so a stale 24p rate would
// keep tunneling withdrawn for a following 60fps item.
final open = await _captureOpen(
configure: (player) async {
await player.setProperty('content-frame-rate', '23.976');
await player.setProperty('content-frame-rate', '0');
},
);
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
test('an unparseable rate is dropped instead of forwarded', () async {
final open = await _captureOpen(configure: (player) => player.setProperty('content-frame-rate', 'nonsense'));
expect(_args(open).containsKey('contentFrameRate'), isFalse);
});
}
+10 -3
View File
@@ -36,12 +36,19 @@ void main() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async {
testWidgets('keeps audio passthrough out of the in-player sheet', (tester) async {
// It configures the audio output route, not this playback, and applying it
// mid-stream bounces the audio renderer and re-decides video tunneling. Settings >
// Video Playback owns it, alongside Tunneled Playback.
await _pumpSheet(tester);
await tester.scrollUntilVisible(find.text('Audio Passthrough'), 500, scrollable: find.byType(Scrollable).first);
final scrollable = find.byType(Scrollable).first;
for (var i = 0; i < 10; i++) {
await tester.drag(scrollable, const Offset(0, -300));
await tester.pumpAndSettle();
}
expect(find.text('Audio Passthrough'), findsOneWidget);
expect(find.text('Audio Passthrough'), findsNothing);
});
testWidgets('localizes Off, Normal, and Active video setting values', (tester) async {