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"))
}
}