fix(live-tv): stabilize HLS playback

This commit is contained in:
edde746
2026-07-18 20:04:09 +02:00
parent 46dfc7f992
commit 0707d4d9b1
27 changed files with 230 additions and 211 deletions
@@ -54,7 +54,6 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
@@ -94,6 +93,8 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
): Boolean = false
}
internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
@OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) : Player.Listener {
@@ -902,7 +903,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
lastDuration = 0L
lastBufferedPosition = 0L
// Dart already seeds the visible timeline before open. Emitting native
// zeroes here races server-offset Plex transcode restarts back to 0:00.
// zeroes here races HLS resume/restart state back to 0:00.
delegate?.onPropertyChange("eof-reached", false)
}
@@ -1203,7 +1204,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction"
)
if (!setCurrentMediaForRetry(player, uri, savedPosition)) return false
player.setMediaItem(buildMediaItem(uri), savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
return true
@@ -1219,21 +1220,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun isEncodedAudioMimeType(mimeType: String): Boolean = mimeType.startsWith("audio/") && mimeType != MimeTypes.AUDIO_RAW
private fun setCurrentMediaForRetry(player: ExoPlayer, uri: String, positionMs: Long): Boolean {
if (currentMediaIsLive) {
val factory = dataSourceFactory ?: return false
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(factory, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
player.setMediaSource(mediaSource, positionMs)
} else {
player.setMediaItem(buildMediaItem(uri), positionMs)
}
return true
}
private fun activeDoviTrackOutput(): DoviConvertingTrackOutput? = activeDoviMkvWrapper?.doviTrackOutput ?: activeDoviMp4Wrapper?.doviTrackOutput
private fun dolbyVisionProfile(format: Format?): Int? {
@@ -2325,6 +2311,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
// Every Live TV backend negotiates HLS before opening the native player.
// Pin the MIME type so tokenized manifests never fall back to progressive
// extension sniffing, and so initial opens and recovery use one source path.
playbackMimeType(currentMediaIsLive)?.let(mediaItemBuilder::setMimeType)
if (externalSubtitles.isNotEmpty()) {
mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList())
}
@@ -2843,28 +2834,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
)
emitSeekable(false, force = true)
if (isLive) {
// Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells
// MatroskaExtractor to not seek for them, treating the stream as unseekable
// so data flows immediately without hanging.
// Headers already applied to httpDataSourceFactory above.
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory!!, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
exoPlayer?.apply {
setMediaSource(mediaSource, startPositionMs)
prepare()
playWhenReady = autoPlay
}
emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback")
return
}
val mediaItem = buildMediaItem(uri)
exoPlayer?.apply {
@@ -2873,7 +2842,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
playWhenReady = autoPlay
}
emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
val sourceLabel = if (isLive) "live HLS" else "media"
emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
}
fun setAudioDelay(seconds: Double) {
@@ -1,6 +1,5 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
@@ -24,10 +23,9 @@ internal val matroskaExtractorOutputField by lazy {
private const val WAVE_FORMAT_MPEG_LOAS = 0x1602
/**
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM ffmpeg's (and
* therefore Plex's) fallback mapping for aac_latm, which Matroska has no native
* codec ID for. The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of
* CodecPrivate.
* Returns whether a track is LOAS/LATM AAC muxed as A_MS/ACM ffmpeg's
* fallback mapping for aac_latm, which Matroska has no native codec ID for.
* The WAVEFORMATEX wFormatTag is the first 2 bytes (LE) of CodecPrivate.
*/
fun isLoasAcmTrack(codecId: String?, codecPrivate: ByteArray?): Boolean = codecId == CODEC_ID_ACM &&
codecPrivate != null &&
@@ -65,48 +63,3 @@ class LatmExtractorOutputWrapper(
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* MatroskaExtractor with LOAS/LATM AAC support, used for Plex Live TV MKV
* streams (which bypass the ASS/DV extractor chain). VOD playback gets the
* same LATM handling via ZlibMatroskaExtractor.
*/
class LatmMatroskaExtractor(flags: Int) : MatroskaExtractor(flags) {
companion object {
private const val TAG = "LatmMkvExtractor"
private const val ID_SEGMENT = 0x18538067
private const val ID_TRACK_ENTRY = 0xAE
}
private var latmWrapper: LatmExtractorOutputWrapper? = null
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
super.startMasterElement(id, contentPosition, contentSize)
// init() is final, so install the wrapping output when the Segment starts —
// before any TrackEntry can create a track through it.
if (id == ID_SEGMENT && latmWrapper == null) {
val currentOutput = matroskaExtractorOutputField.get(this) as ExtractorOutput
val wrapper = LatmExtractorOutputWrapper(currentOutput)
latmWrapper = wrapper
matroskaExtractorOutputField.set(this, wrapper)
}
}
override fun endMasterElement(id: Int) {
if (id == ID_TRACK_ENTRY) {
val track = getCurrentTrack(id)
if (isLoasAcmTrack(track.codecId, track.codecPrivate)) {
Log.i(TAG, "Track ${track.number} is LOAS/LATM AAC, unwrapping to raw AAC")
latmWrapper?.markNextTrackLatm()
}
}
super.endMasterElement(id)
}
override fun seek(position: Long, timeUs: Long) {
latmWrapper?.resetTracks()
super.seek(position, timeUs)
}
}
@@ -21,8 +21,7 @@ import com.edde746.plezy.libass.media.extractor.AssMatroskaExtractor
*
* LOAS/LATM AAC as A_MS/ACM — media3 sets audio/x-unknown for non-PCM ACM
* tracks (silent playback). Detected tracks are wrapped with LatmTrackOutput,
* which unwraps LOAS frames to raw AAC (see LatmMatroskaExtractor for the
* Live TV counterpart).
* which unwraps LOAS frames to raw AAC for direct-playing Matroska files.
*/
class ZlibMatroskaExtractor(
subtitleParserFactory: SubtitleParser.Factory,
@@ -11,7 +11,8 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import java.io.EOFException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -24,16 +25,15 @@ import org.robolectric.annotation.Config
/**
* Extracts the committed fixture (1s 440Hz sine, AAC-LC 48kHz stereo, LATM/LOAS
* muxed into MKV as A_MS/ACM tag 0x1602 the layout Plex produces when
* Direct-Streaming HDHomeRun aac_latm audio) and verifies LOAS frames are
* unwrapped to raw AAC with a synthesized AudioSpecificConfig.
* muxed into MKV as A_MS/ACM tag 0x1602) and verifies direct Matroska playback
* unwraps LOAS frames to raw AAC with a synthesized AudioSpecificConfig.
*
* Robolectric provides real android.util.* implementations MatroskaExtractor
* stores tracks in a SparseArray, which is a no-op stub on plain JVM.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LatmMatroskaExtractorTest {
class MatroskaLatmSupportTest {
private class CapturedSample(val timeUs: Long, val flags: Int, val data: ByteArray)
@@ -121,7 +121,8 @@ class LatmMatroskaExtractorTest {
private fun extractFixture(): FakeExtractorOutput {
val data = fixtureData()
val extractor = LatmMatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES)
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = FakeExtractorOutput()
extractor.init(output)
@@ -0,0 +1,19 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.MimeTypes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PlaybackMimeTypeTest {
@Test
fun livePlaybackIsAlwaysHls() {
assertEquals(MimeTypes.APPLICATION_M3U8, playbackMimeType(isLive = true))
}
@Test
fun nonLivePlaybackUsesNormalSourceInference() {
assertNull(playbackMimeType(isLive = false))
}
}