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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user