fix: persist audiotrack on stream reload

fixes #1163
This commit is contained in:
tapframe 2026-05-27 11:31:54 +05:30
parent 03f8d89655
commit 0c0c616bed

View file

@ -30,6 +30,7 @@ import org.jetbrains.compose.resources.getString
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
@ -119,6 +120,43 @@ actual fun PlatformPlayerSurface(
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val extractorsFactory = remember {
DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
}
val dataSourceFactory = remember(
context,
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
) {
PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
)
}
fun ExoPlayer.setPlaybackMediaItem(videoMediaItem: MediaItem, startPositionMs: Long? = null) {
if (!sourceAudioUrl.isNullOrBlank()) {
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem)
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
val mergedSource = MergingMediaSource(videoSource, audioSource)
if (startPositionMs != null) {
setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L))
} else {
setMediaSource(mergedSource)
}
} else if (startPositionMs != null) {
setMediaItem(videoMediaItem, startPositionMs.coerceAtLeast(0L))
} else {
setMediaItem(videoMediaItem)
}
}
val exoPlayer = remember(
sourceUrl,
sourceAudioUrl,
@ -158,17 +196,6 @@ actual fun PlatformPlayerSurface(
)
.build()
val extractorsFactory = DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
)
val player = if (useLibass) {
ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
@ -195,21 +222,17 @@ actual fun PlatformPlayerSurface(
}
player.apply {
if (!sourceAudioUrl.isNullOrBlank()) {
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))
}
fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) }
prepare()
this.playWhenReady = playWhenReady
}
setPlaybackMediaItem(
videoMediaItem = MediaItem.fromUri(sourceUrl),
startPositionMs = fallbackStartPositionMs,
)
prepare()
this.playWhenReady = playWhenReady
}
}
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
val pendingAudioTrackSelection = remember { mutableListOf<TrackSelectionSnapshot>() }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
@ -218,6 +241,13 @@ actual fun PlatformPlayerSurface(
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
}
fun preserveAudioSelectionForReload(reason: String) {
pendingAudioTrackSelection.clear()
val selection = exoPlayer.captureSelectedTrack(C.TRACK_TYPE_AUDIO) ?: return
pendingAudioTrackSelection.add(selection)
Log.d(TAG, "$reason: preserving audio track index=${selection.index} id=${selection.id}")
}
DisposableEffect(exoPlayer) {
PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause()
@ -274,6 +304,13 @@ actual fun PlatformPlayerSurface(
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
Log.d(TAG, "onTracksChanged: ${tracks.groups.size} groups total")
exoPlayer.logCurrentTracks("onTracksChanged")
pendingAudioTrackSelection.firstOrNull()?.let { selection ->
if (tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO }) {
pendingAudioTrackSelection.clear()
val restored = exoPlayer.restoreTrackSelection(selection)
Log.d(TAG, "onTracksChanged: restored pending audio selection=$restored")
}
}
if (pendingSubtitleTrackIndex.isNotEmpty() && tracks.groups.isNotEmpty()) {
val idx = pendingSubtitleTrackIndex.removeAt(0)
Log.d(TAG, "onTracksChanged: applying pending subtitle selection index=$idx")
@ -401,6 +438,7 @@ actual fun PlatformPlayerSurface(
Log.e(TAG, "setSubtitleUri: currentMediaItem is null, aborting")
return@launch
}
preserveAudioSelectionForReload("setSubtitleUri")
val resolvedMime = withContext(Dispatchers.IO) {
resolveSubtitleMimeType(url)
}
@ -426,7 +464,7 @@ actual fun PlatformPlayerSurface(
.setPreferredTextRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
Log.d(TAG, "setSubtitleUri: track params set before prepare, textDisabled=${exoPlayer.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)}")
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "setSubtitleUri: prepare() called, waiting for STATE_READY")
@ -440,10 +478,11 @@ actual fun PlatformPlayerSurface(
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
preserveAudioSelectionForReload("clearExternalSubtitle")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitle: done, position=$currentPosition")
@ -458,10 +497,11 @@ actual fun PlatformPlayerSurface(
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
preserveAudioSelectionForReload("clearExternalSubtitleAndSelect")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitleAndSelect: done, pending=$trackIndex position=$currentPosition")
@ -544,6 +584,86 @@ private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
playWhenReady &&
playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY)
private data class TrackSelectionSnapshot(
val trackType: Int,
val index: Int,
val id: String?,
val language: String?,
val label: String?,
val sampleMimeType: String?,
val codecs: String?,
val channelCount: Int,
val roleFlags: Int,
)
private fun ExoPlayer.captureSelectedTrack(trackType: Int): TrackSelectionSnapshot? {
var idx = 0
for (group in currentTracks.groups) {
if (group.type != trackType) continue
if (group.isSelected) {
val format = group.mediaTrackGroup.getFormat(0)
return TrackSelectionSnapshot(
trackType = trackType,
index = idx,
id = format.id,
language = format.language,
label = format.label,
sampleMimeType = format.sampleMimeType,
codecs = format.codecs,
channelCount = format.channelCount,
roleFlags = format.roleFlags,
)
}
idx++
}
return null
}
private fun ExoPlayer.restoreTrackSelection(selection: TrackSelectionSnapshot): Boolean {
selection.id?.takeIf { it.isNotBlank() }?.let { id ->
val restored = selectTrackByPredicate(selection.trackType, "id=$id") { _, format ->
format.id == id
}
if (restored) {
return true
}
}
selection.label?.takeIf { it.isNotBlank() }?.let { label ->
val restored = selectTrackByPredicate(selection.trackType, "label=$label") { _, format ->
format.label.equals(label, ignoreCase = true) &&
(selection.language.isNullOrBlank() ||
format.language.equals(selection.language, ignoreCase = true))
}
if (restored) {
return true
}
}
val technicalMatchIndexes = mutableListOf<Int>()
var idx = 0
for (group in currentTracks.groups) {
if (group.type != selection.trackType) continue
val format = group.mediaTrackGroup.getFormat(0)
if (
!selection.language.isNullOrBlank() &&
format.language.equals(selection.language, ignoreCase = true) &&
format.sampleMimeType == selection.sampleMimeType &&
format.codecs == selection.codecs &&
format.channelCount == selection.channelCount &&
format.roleFlags == selection.roleFlags
) {
technicalMatchIndexes.add(idx)
}
idx++
}
if (technicalMatchIndexes.size == 1) {
return selectTrackByIndex(selection.trackType, technicalMatchIndexes.first())
}
return selectTrackByIndex(selection.trackType, selection.index)
}
private fun PlaybackException.isDecoderFailure(): Boolean =
errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
@ -694,27 +814,39 @@ private fun ExoPlayer.extractSubtitleTracks(context: Context): List<SubtitleTrac
return tracks
}
private fun ExoPlayer.selectTrackByIndex(trackType: Int, targetIndex: Int) {
private fun ExoPlayer.selectTrackByIndex(trackType: Int, targetIndex: Int): Boolean {
return selectTrackByPredicate(trackType, "index=$targetIndex") { idx, _ ->
idx == targetIndex
}
}
private fun ExoPlayer.selectTrackByPredicate(
trackType: Int,
targetDescription: String,
predicate: (index: Int, format: Format) -> Boolean,
): Boolean {
val typeName = if (trackType == C.TRACK_TYPE_AUDIO) "AUDIO" else "TEXT"
Log.d(TAG, "selectTrackByIndex: type=$typeName targetIndex=$targetIndex")
Log.d(TAG, "selectTrack: type=$typeName target=$targetDescription")
var idx = 0
for (group in currentTracks.groups) {
if (group.type != trackType) continue
if (idx == targetIndex) {
val format = group.mediaTrackGroup.getFormat(0)
Log.d(TAG, "selectTrackByIndex: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
)
.build()
Log.d(TAG, "selectTrackByIndex: override applied")
return
val format = group.mediaTrackGroup.getFormat(0)
if (!predicate(idx, format)) {
idx++
continue
}
idx++
Log.d(TAG, "selectTrack: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
)
.build()
Log.d(TAG, "selectTrack: override applied")
return true
}
Log.w(TAG, "selectTrackByIndex: no group found for type=$typeName at index=$targetIndex (total groups scanned=$idx)")
Log.w(TAG, "selectTrack: no group found for type=$typeName target=$targetDescription (total groups scanned=$idx)")
return false
}
private fun ExoPlayer.logCurrentTracks(context: String) {