diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 9e017222f..9d2c97bd5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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(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() } + val pendingAudioTrackSelection = remember { mutableListOf() } var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(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() + 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 + 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) {