fix(live-tv): stabilize HLS playback
This commit is contained in:
@@ -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) {
|
||||
|
||||
+3
-50
@@ -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,
|
||||
|
||||
+7
-6
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user