From 5d6f34f756a769faa2da4f192c8ed9ffbdf5439e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 20 May 2026 18:27:42 +0200 Subject: [PATCH] fix(android): preserve tracks on DV reload --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 296 ++++++++++++++++-- 1 file changed, 269 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5d7415aa..f230229a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -188,6 +188,35 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var selectedSubtitleTrackId: String? = null private val audioTrackGroupMap = mutableMapOf() private val subtitleTrackGroupMap = mutableMapOf() + private var pendingDvTrackRestore: PendingTrackRestore? = null + + private data class PendingTrackRestore( + val audio: TrackRestoreIdentity?, + val subtitle: TrackRestoreIdentity?, + val subtitleDisabled: Boolean + ) + + private data class TrackRestoreIdentity( + val type: Int, + val groupIndex: Int, + val trackIndex: Int, + val formatId: String?, + val label: String?, + val language: String?, + val sampleMimeType: String?, + val codecs: String?, + val channelCount: Int, + val sampleRate: Int, + val selectionFlags: Int + ) + + private data class TrackRestoreMatch( + val group: Tracks.Group, + val trackGroup: TrackGroup, + val trackIndex: Int, + val format: Format, + val score: Int + ) private fun emitLog(level: String, prefix: String, message: String) { when (level) { @@ -787,6 +816,24 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { override fun onTracksChanged(tracks: Tracks) { Log.d(TAG, "onTracksChanged") + // Detect video track present but deselected (unsupported codec — plays audio only) + val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } + val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { + // Try DV conversion before falling to MPV + if (retryWithDvConversion("video track not selected")) return + emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") + delegate?.onFormatUnsupported( + uri = currentMediaUri!!, + headers = currentHeaders, + positionMs = effectivePosition, + errorMessage = "Video track present but no decoder available" + ) + return + } + + if (restorePendingDvTrackSelection(tracks)) return + // Log selected video and audio track details val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } @@ -805,21 +852,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { updateAudioDecoderPolicy("tracks changed", af) } } - // Detect video track present but deselected (unsupported codec — plays audio only) - val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } - val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } - if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { - // Try DV conversion before falling to MPV - if (retryWithDvConversion("video track not selected")) return - emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") - delegate?.onFormatUnsupported( - uri = currentMediaUri!!, - headers = currentHeaders, - positionMs = effectivePosition, - errorMessage = "Video track present but no decoder available" - ) - return - } evaluateAudioCodecForTunneling() evaluateVideoCodecForTunneling() @@ -895,7 +927,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { if (debugDvModeOverride == DvConversionMode.DISABLED) return false if (dvMode != DvConversionMode.DISABLED) return false if (!DoviBridge.isAvailable()) return false - val uri = currentMediaUri ?: return false + if (currentMediaUri == null) return false dv7RetryAttempted = true val newMode = DoviBridge.getDv7FallbackMode() @@ -903,13 +935,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { Log.i(TAG, "Native DV7 playback failed ($reason), retrying with $newMode") emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode") - open( - uri = uri, - headers = currentHeaders, - startPositionMs = lastPosition, - autoPlay = true - ) - return true + return reloadCurrentMediaForDvMode() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { @@ -1101,6 +1127,207 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { delegate?.onPropertyChange("track-list", trackList) } + private fun captureDvTrackRestore(): PendingTrackRestore? { + val tracks = exoPlayer?.currentTracks ?: return null + val audio = captureSelectedTrackIdentity(tracks, C.TRACK_TYPE_AUDIO) + ?: captureMappedTrackIdentity(selectedAudioTrackId, audioTrackGroupMap, C.TRACK_TYPE_AUDIO) + val subtitle = captureSelectedTrackIdentity(tracks, C.TRACK_TYPE_TEXT) + ?: captureMappedTrackIdentity(selectedSubtitleTrackId, subtitleTrackGroupMap, C.TRACK_TYPE_TEXT) + val subtitleDisabled = subtitle == null && selectedSubtitleTrackId == "no" + + if (audio == null && subtitle == null && !subtitleDisabled) return null + return PendingTrackRestore(audio, subtitle, subtitleDisabled) + } + + private fun captureSelectedTrackIdentity(tracks: Tracks, type: Int): TrackRestoreIdentity? { + var typeGroupIndex = 0 + for (group in tracks.groups) { + if (group.type != type) continue + val trackIndex = selectedTrackIndex(group) + if (trackIndex != null) { + return buildTrackRestoreIdentity(type, typeGroupIndex, trackIndex, group.mediaTrackGroup.getFormat(trackIndex)) + } + typeGroupIndex++ + } + return null + } + + private fun captureMappedTrackIdentity( + trackId: String?, + trackMap: Map, + type: Int + ): TrackRestoreIdentity? { + if (trackId == null || trackId == "no") return null + val trackGroup = trackMap[trackId] ?: return null + if (trackGroup.length == 0) return null + val groupIndex = trackId.substringAfter('_', "").toIntOrNull() ?: -1 + return buildTrackRestoreIdentity(type, groupIndex, 0, trackGroup.getFormat(0)) + } + + private fun selectedTrackIndex(group: Tracks.Group): Int? { + if (!group.isSelected) return null + for (trackIndex in 0 until group.mediaTrackGroup.length) { + if (group.isTrackSelected(trackIndex)) return trackIndex + } + return if (group.mediaTrackGroup.length > 0) 0 else null + } + + private fun buildTrackRestoreIdentity( + type: Int, + groupIndex: Int, + trackIndex: Int, + format: Format + ): TrackRestoreIdentity = TrackRestoreIdentity( + type = type, + groupIndex = groupIndex, + trackIndex = trackIndex, + formatId = normalizedText(format.id), + label = normalizedText(format.label), + language = normalizedLanguage(format.language), + sampleMimeType = normalizedText(format.sampleMimeType), + codecs = normalizedText(format.codecs), + channelCount = format.channelCount, + sampleRate = format.sampleRate, + selectionFlags = format.selectionFlags + ) + + private fun restorePendingDvTrackSelection(tracks: Tracks): Boolean { + val pending = pendingDvTrackRestore ?: return false + val selector = trackSelector ?: return false + if (shouldWaitForDvRestoreTracks(pending, tracks)) return true + pendingDvTrackRestore = null + + val audioMatch = pending.audio?.let { findTrackRestoreMatch(tracks, it) } + val subtitleMatch = pending.subtitle?.let { findTrackRestoreMatch(tracks, it) } + val hasSelectedText = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected } + var selectionWillChange = false + var appliedRestore = false + + val builder = selector.buildUponParameters() + if (pending.audio != null) { + if (audioMatch != null) { + builder + .setOverrideForType(TrackSelectionOverride(audioMatch.trackGroup, audioMatch.trackIndex)) + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + selectionWillChange = selectionWillChange || !audioMatch.isSelected() + appliedRestore = true + emitLog("info", "track-restore", "Restoring audio after DV reload: ${pending.audio.describe()} -> ${audioMatch.format.describeTrackFormat()} score=${audioMatch.score}") + } else { + emitLog("warn", "track-restore", "Could not restore audio after DV reload: ${pending.audio.describe()}") + } + } + + if (pending.subtitleDisabled) { + builder + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + selectionWillChange = selectionWillChange || hasSelectedText + appliedRestore = true + emitLog("info", "track-restore", "Restoring subtitles off after DV reload") + } else if (pending.subtitle != null) { + if (subtitleMatch != null) { + builder + .setOverrideForType(TrackSelectionOverride(subtitleMatch.trackGroup, subtitleMatch.trackIndex)) + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + selectionWillChange = selectionWillChange || !subtitleMatch.isSelected() + appliedRestore = true + emitLog("info", "track-restore", "Restoring subtitle after DV reload: ${pending.subtitle.describe()} -> ${subtitleMatch.format.describeTrackFormat()} score=${subtitleMatch.score}") + } else { + emitLog("warn", "track-restore", "Could not restore subtitle after DV reload: ${pending.subtitle.describe()}") + } + } + + if (appliedRestore) { + selector.parameters = builder.build() + } + return selectionWillChange + } + + private fun shouldWaitForDvRestoreTracks(pending: PendingTrackRestore, tracks: Tracks): Boolean { + val hasAudioGroups = tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO } + val hasTextGroups = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT } + return (pending.audio != null && !hasAudioGroups) || + (pending.subtitle != null && !hasTextGroups) + } + + private fun findTrackRestoreMatch(tracks: Tracks, identity: TrackRestoreIdentity): TrackRestoreMatch? { + var bestMatch: TrackRestoreMatch? = null + var typeGroupIndex = 0 + for (group in tracks.groups) { + if (group.type != identity.type) continue + val trackGroup = group.mediaTrackGroup + for (trackIndex in 0 until trackGroup.length) { + val format = trackGroup.getFormat(trackIndex) + val score = scoreTrackRestoreMatch(identity, format, typeGroupIndex, trackIndex) + if (bestMatch == null || score > bestMatch.score) { + bestMatch = TrackRestoreMatch(group, trackGroup, trackIndex, format, score) + } + } + typeGroupIndex++ + } + + val minimumScore = if (identity.type == C.TRACK_TYPE_AUDIO) 35 else 30 + return bestMatch?.takeIf { it.score >= minimumScore } + } + + private fun scoreTrackRestoreMatch( + identity: TrackRestoreIdentity, + format: Format, + groupIndex: Int, + trackIndex: Int + ): Int { + var score = 0 + val candidateId = normalizedText(format.id) + if (identity.formatId != null && candidateId != null) { + score += if (identity.formatId == candidateId) 100 else -15 + } + val candidateLabel = normalizedText(format.label) + if (identity.label != null && candidateLabel != null && identity.label == candidateLabel) score += 40 + val candidateLanguage = normalizedLanguage(format.language) + if (identity.language != null && candidateLanguage != null && identity.language == candidateLanguage) score += 30 + val candidateMime = normalizedText(format.sampleMimeType) + if (identity.sampleMimeType != null && candidateMime != null && identity.sampleMimeType == candidateMime) score += 12 + val candidateCodecs = normalizedText(format.codecs) + if (identity.codecs != null && candidateCodecs != null && identity.codecs == candidateCodecs) score += 8 + if (identity.channelCount != Format.NO_VALUE && identity.channelCount == format.channelCount) score += 6 + if (identity.sampleRate != Format.NO_VALUE && identity.sampleRate == format.sampleRate) score += 4 + if (identity.selectionFlags == format.selectionFlags) score += 3 + if (identity.groupIndex == groupIndex) score += 20 + if (identity.trackIndex == trackIndex) score += 5 + return score + } + + private fun TrackRestoreMatch.isSelected(): Boolean = group.isSelected && group.isTrackSelected(trackIndex) + + private fun TrackRestoreIdentity.describe(): String { + val parts = mutableListOf() + language?.let { parts.add("lang=$it") } + label?.let { parts.add("label=$it") } + sampleMimeType?.let { parts.add("mime=$it") } + codecs?.let { parts.add("codecs=$it") } + if (channelCount != Format.NO_VALUE) parts.add("channels=$channelCount") + if (sampleRate != Format.NO_VALUE) parts.add("rate=$sampleRate") + formatId?.let { parts.add("id=$it") } + parts.add("group=$groupIndex") + return parts.joinToString(",") + } + + private fun Format.describeTrackFormat(): String { + val parts = mutableListOf() + normalizedLanguage(language)?.let { parts.add("lang=$it") } + normalizedText(label)?.let { parts.add("label=$it") } + normalizedText(sampleMimeType)?.let { parts.add("mime=$it") } + normalizedText(codecs)?.let { parts.add("codecs=$it") } + if (channelCount != Format.NO_VALUE) parts.add("channels=$channelCount") + if (sampleRate != Format.NO_VALUE) parts.add("rate=$sampleRate") + normalizedText(id)?.let { parts.add("id=$it") } + return parts.joinToString(",") + } + + private fun normalizedText(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } + + private fun normalizedLanguage(value: String?): String? = normalizedText(value)?.lowercase()?.takeUnless { it == "und" } + // Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg) private data class TrueHdDirectOutputDecision( @@ -1714,6 +1941,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { subtitleTrackGroupMap.clear() selectedAudioTrackId = null selectedSubtitleTrackId = null + pendingDvTrackRestore = null // Build external subtitle configurations (attached to MediaItem before prepare) externalSubtitleList?.forEachIndexed { index, sub -> @@ -1802,18 +2030,30 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return true } - private fun reloadCurrentMediaForDvMode() { - val player = exoPlayer ?: return - val uri = currentMediaUri ?: return - if (currentMediaIsLive) return + private fun reloadCurrentMediaForDvMode(): Boolean { + val player = exoPlayer ?: return false + val uri = currentMediaUri ?: return false + if (currentMediaIsLive) return false val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs) val savedPlayWhenReady = player.playWhenReady + pendingDvTrackRestore = captureDvTrackRestore()?.also { restore -> + emitLog( + "info", + "track-restore", + "Saved selection before DV reload: audio=${restore.audio?.describe() ?: "none"}, " + + "subtitle=${restore.subtitle?.describe() ?: if (restore.subtitleDisabled) "off" else "none"}" + ) + } pendingStartPositionMs = savedPosition pendingPlayWhenReady = savedPlayWhenReady decoderInitName = null audioDecoderInitName = null + detectedFrameRate = -1f + fpsTimestampCount = 0 firstFrameRendered = false + activeDoviMkvWrapper = null + activeDoviMp4Wrapper = null stopFrameWatchdog() cancelDecoderHangCheck() @@ -1830,6 +2070,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { player.prepare() player.playWhenReady = savedPlayWhenReady emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms") + return true } fun play() { @@ -2293,6 +2534,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { emitSeekable(false, force = true) selectedAudioTrackId = null selectedSubtitleTrackId = null + pendingDvTrackRestore = null audioTrackGroupMap.clear() subtitleTrackGroupMap.clear() exoPlayer?.clearVideoSurface()