fix: reopen ExoPlayer when tunneling mode changes

This commit is contained in:
edde746
2026-03-06 13:49:03 +01:00
parent cf8f1849aa
commit 43f4736936
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.Gravity
import android.view.SurfaceView
@@ -74,6 +75,16 @@ interface ExoPlayerDelegate {
@OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private data class SubtitleStyleState(
val fontSize: Float,
val textColor: String,
val borderSize: Float,
val borderColor: String,
val bgColor: String,
val bgOpacity: Int,
val subtitlePosition: Int,
)
companion object {
private const val TAG = "ExoPlayerCore"
@@ -99,6 +110,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var tunnelingDisabledForVideoCodec: Boolean = false
private val tunnelingDisabledForCodec: Boolean
get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec
private var currentTunneledPlayback: Boolean = false
private var tunnelingCorrectionInProgress: Boolean = false
private var pendingRestoredAudioTrackId: String? = null
private var pendingRestoredSubtitleTrackId: String? = null
private var lastTunnelingCorrectionTarget: Boolean? = null
private var lastTunnelingCorrectionAtMs: Long = 0L
@Volatile private var disposing: Boolean = false
private var pendingStartPositionMs: Long = 0L
@@ -131,6 +148,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
private var currentMediaUri: String? = null
private var currentHeaders: Map<String, String>? = null
private var currentMediaIsLive: Boolean = false
private var currentVisible: Boolean = false
private var selectedAudioTrackId: String? = null
private var selectedSubtitleTrackId: String? = null
private var selectedExternalSubtitleIndex: Int? = null
private var subtitleStyleState: SubtitleStyleState? = null
private var reapplySubtitleStyleOnNextReady: Boolean = false
private fun emitLog(level: String, prefix: String, message: String) {
when (level) {
@@ -297,7 +321,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
trackSelector = DefaultTrackSelector(activity).apply {
setParameters(
buildUponParameters()
.setTunnelingEnabled(tunnelingUserEnabled)
.setTunnelingEnabled(false)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage("en")
)
@@ -547,6 +571,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
delegate?.onPropertyChange("paused-for-cache", false)
delegate?.onEvent("playback-restart", null)
applyStoredSubtitleStyleIfNeeded()
emitTrackList()
// Start frame watchdog to detect black screen (HDR tunneling issue)
@@ -595,6 +620,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
evaluateAudioCodecForTunneling()
evaluateVideoCodecForTunneling()
captureSelectedTrackIds(tracks)
if (maybeRestoreTrackSelectionsAfterReopen()) {
emitTrackList()
return
}
updateTunnelingState("tracks changed")
emitTrackList()
}
@@ -786,12 +817,20 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
// Emit selected track IDs
selectedAudioId?.let { delegate?.onPropertyChange("aid", it) }
if (selectedSubId != null) {
delegate?.onPropertyChange("sid", selectedSubId)
} else if (textGroups.isNotEmpty()) {
// No subtitle selected
delegate?.onPropertyChange("sid", "no")
if (selectedAudioId != null) {
selectedAudioTrackId = selectedAudioId
delegate?.onPropertyChange("aid", selectedAudioId)
}
val effectiveSubtitleId = when {
selectedExternalSubtitleIndex != null -> "ext_sub_$selectedExternalSubtitleIndex"
selectedSubId != null -> selectedSubId
textGroups.isNotEmpty() || externalSubtitles.isNotEmpty() -> "no"
else -> null
}
if (effectiveSubtitleId != null) {
selectedSubtitleTrackId = effectiveSubtitleId
delegate?.onPropertyChange("sid", effectiveSubtitleId)
}
// Add external subtitles to track list
@@ -804,6 +843,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"lang" to subtitle.language,
"codec" to subtitle.mimeType,
"default" to false,
"selected" to (selectedExternalSubtitleIndex == index),
"external" to true,
"external-filename" to subtitle.uri.toString()
))
@@ -899,27 +939,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (newDisabled != tunnelingDisabledForVideoCodec) {
tunnelingDisabledForVideoCodec = newDisabled
emitLog("info", "tunneling", "Video codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no tunneling support)" else "enabled"}")
updateTunnelingState()
}
}
private fun updateTunnelingState() {
val selector = trackSelector ?: return
private fun updateTunnelingState(reason: String) {
val player = exoPlayer ?: return
val currentSpeed = player.playbackParameters.speed
val shouldTunnel = tunnelingUserEnabled && (currentSpeed == 1f) && !tunnelingDisabledForCodec
val currentTunneling = selector.parameters.tunnelingEnabled
if (shouldTunnel == currentTunneling) return // No change needed
emitLog("info", "tunneling", "tunneling $currentTunneling -> $shouldTunnel (user=$tunnelingUserEnabled, speed=$currentSpeed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec)")
selector.setParameters(
selector.buildUponParameters()
.setTunnelingEnabled(shouldTunnel)
)
// Track reselection from setParameters() can reset position during initial load.
// Restore the pending start position if it hasn't been consumed yet.
if (pendingStartPositionMs > 0L) {
player.seekTo(pendingStartPositionMs)
}
val desiredTunneling = desiredTunnelingMode(player)
if (desiredTunneling == currentTunneledPlayback) return
emitLog("info", "tunneling", "Mode mismatch current=$currentTunneledPlayback desired=$desiredTunneling (reason=$reason, user=$tunnelingUserEnabled, speed=${player.playbackParameters.speed}, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec)")
requestTunnelingCorrectionReopen(desiredTunneling, reason)
}
private fun evaluateAudioCodecForTunneling() {
@@ -935,7 +963,193 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (newDisabled != tunnelingDisabledForAudioCodec) {
tunnelingDisabledForAudioCodec = newDisabled
emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}")
updateTunnelingState()
}
}
private fun desiredTunnelingMode(player: ExoPlayer): Boolean {
if (!tunnelingUserEnabled) return false
if (player.playbackParameters.speed != 1f) return false
val selectedVideoGroup = player.currentTracks.groups.firstOrNull {
it.type == C.TRACK_TYPE_VIDEO && it.isSelected
} ?: return false
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
it.type == C.TRACK_TYPE_AUDIO && it.isSelected
} ?: return false
val videoMimeType = selectedVideoGroup.mediaTrackGroup.getFormat(0).sampleMimeType ?: return false
val audioMimeType = selectedAudioGroup.mediaTrackGroup.getFormat(0).sampleMimeType ?: return false
return videoCodecSupportsTunneledPlayback(videoMimeType) && hasHardwareAudioDecoder(audioMimeType)
}
private fun requestTunnelingCorrectionReopen(targetTunneledPlayback: Boolean, reason: String) {
val uri = currentMediaUri ?: return
val player = exoPlayer ?: return
if (tunnelingCorrectionInProgress) {
emitLog("debug", "tunneling", "Skipping reopen to $targetTunneledPlayback while correction is already in progress")
return
}
val now = SystemClock.elapsedRealtime()
if (lastTunnelingCorrectionTarget == targetTunneledPlayback &&
now - lastTunnelingCorrectionAtMs < 3000L) {
emitLog("warn", "tunneling", "Skipping reopen to $targetTunneledPlayback to avoid correction loop")
return
}
val shouldResume = player.playWhenReady
tunnelingCorrectionInProgress = true
pendingRestoredAudioTrackId = selectedAudioTrackId
pendingRestoredSubtitleTrackId = selectedSubtitleTrackId
lastTunnelingCorrectionTarget = targetTunneledPlayback
lastTunnelingCorrectionAtMs = now
currentTunneledPlayback = targetTunneledPlayback
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
pendingStartPositionMs = player.currentPosition
reapplySubtitleStyleOnNextReady = true
applyTunnelingMode(targetTunneledPlayback)
emitLog(
"info",
"tunneling",
"Reopening playback with tunneling=$targetTunneledPlayback (reason=$reason, position=${pendingStartPositionMs}ms, audio=$pendingRestoredAudioTrackId, subtitle=$pendingRestoredSubtitleTrackId)"
)
if (currentMediaIsLive) {
val reopenHeaders = currentHeaders
val dataSourceFactory = if (!reopenHeaders.isNullOrEmpty()) {
DefaultDataSource.Factory(activity,
androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(reopenHeaders))
} else {
DefaultDataSource.Factory(activity)
}
val extractorsFactory = androidx.media3.extractor.ExtractorsFactory {
arrayOf(MatroskaExtractor(MatroskaExtractor.FLAG_DISABLE_SEEK_FOR_CUES))
}
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(MediaItem.fromUri(uri))
player.setMediaSource(mediaSource, pendingStartPositionMs)
player.prepare()
player.playWhenReady = shouldResume
setVisible(currentVisible)
return
}
player.setMediaItem(buildMediaItem(uri), pendingStartPositionMs)
player.prepare()
player.playWhenReady = shouldResume
setVisible(currentVisible)
}
private fun captureSelectedTrackIds(tracks: Tracks) {
val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
val selectedAudioIndex = audioGroups.indexOfFirst { it.isSelected }
val selectedSubtitleIndex = textGroups.indexOfFirst { it.isSelected }
selectedAudioTrackId = selectedAudioIndex.takeIf { it >= 0 }?.let { "${C.TRACK_TYPE_AUDIO}_$it" }
selectedSubtitleTrackId = when {
selectedExternalSubtitleIndex != null -> "ext_sub_$selectedExternalSubtitleIndex"
selectedSubtitleIndex >= 0 -> "${C.TRACK_TYPE_TEXT}_$selectedSubtitleIndex"
textGroups.isNotEmpty() || externalSubtitles.isNotEmpty() -> "no"
else -> null
}
}
private fun maybeRestoreTrackSelectionsAfterReopen(): Boolean {
if (!tunnelingCorrectionInProgress) return false
val audioToRestore = pendingRestoredAudioTrackId
if (audioToRestore != null && audioToRestore != selectedAudioTrackId) {
val audioIndex = audioToRestore.substringAfter('_', "").toIntOrNull()
val player = exoPlayer
if (player != null && audioIndex != null) {
val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
if (audioIndex in audioGroups.indices) {
emitLog("info", "tunneling", "Restoring audio track $audioToRestore after reopen")
selectAudioTrack(audioToRestore)
return true
}
}
}
val subtitleToRestore = pendingRestoredSubtitleTrackId
if (subtitleToRestore != null && subtitleToRestore != selectedSubtitleTrackId) {
when {
subtitleToRestore == "no" -> {
emitLog("info", "tunneling", "Restoring subtitles off after reopen")
selectSubtitleTrack("no")
return true
}
subtitleToRestore.startsWith("ext_sub_") -> {
val externalIndex = subtitleToRestore.removePrefix("ext_sub_").toIntOrNull()
if (externalIndex != null && selectedExternalSubtitleIndex != externalIndex && externalIndex in externalSubtitles.indices) {
emitLog("info", "tunneling", "Restoring external subtitle $subtitleToRestore after reopen")
selectSubtitleTrack(subtitleToRestore)
return true
}
}
else -> {
val subtitleIndex = subtitleToRestore.substringAfter('_', "").toIntOrNull()
val player = exoPlayer
if (player != null && subtitleIndex != null) {
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (subtitleIndex in textGroups.indices) {
emitLog("info", "tunneling", "Restoring subtitle track $subtitleToRestore after reopen")
selectSubtitleTrack(subtitleToRestore)
return true
}
}
}
}
}
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
tunnelingCorrectionInProgress = false
emitLog("info", "tunneling", "Tunneling correction reopen completed")
return false
}
private fun applyTunnelingMode(enabled: Boolean) {
val selector = trackSelector ?: return
selector.parameters = selector.buildUponParameters()
.setTunnelingEnabled(enabled)
.build()
}
private fun buildMediaItem(uri: String): MediaItem {
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
selectedExternalSubtitleIndex?.takeIf { it in externalSubtitles.indices }?.let { subtitleIndex ->
mediaItemBuilder.setSubtitleConfigurations(listOf(externalSubtitles[subtitleIndex]))
}
return mediaItemBuilder.build()
}
private fun applyStoredSubtitleStyleIfNeeded() {
if (!reapplySubtitleStyleOnNextReady) return
reapplySubtitleStyleOnNextReady = false
subtitleStyleState?.let {
setSubtitleStyle(
fontSize = it.fontSize,
textColor = it.textColor,
borderSize = it.borderSize,
borderColor = it.borderColor,
bgColor = it.bgColor,
bgOpacity = it.bgOpacity,
subtitlePosition = it.subtitlePosition,
)
}
}
@@ -1084,10 +1298,22 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentMediaUri = uri
currentHeaders = headers
currentMediaIsLive = isLive
externalSubtitles.clear()
selectedExternalSubtitleIndex = null
selectedAudioTrackId = null
selectedSubtitleTrackId = null
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
tunnelingCorrectionInProgress = false
lastTunnelingCorrectionTarget = null
lastTunnelingCorrectionAtMs = 0L
reapplySubtitleStyleOnNextReady = false
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
currentTunneledPlayback = false
pendingStartPositionMs = startPositionMs
applyTunnelingMode(false)
if (isLive) {
// Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells
@@ -1114,22 +1340,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
playWhenReady = autoPlay
}
emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay")
emitLog("info", "media", "Opened live: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback")
return
}
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
// Add headers if provided
if (!headers.isNullOrEmpty()) {
val dataSourceFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(headers)
// Note: For proper header support, we'd need to configure this at player level
// For now, headers are handled via URL parameters if needed
}
val mediaItem = mediaItemBuilder.build()
val mediaItem = buildMediaItem(uri)
exoPlayer?.apply {
setMediaItem(mediaItem, startPositionMs)
@@ -1137,7 +1352,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
playWhenReady = autoPlay
}
emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, tunneling=$tunnelingUserEnabled")
emitLog("info", "media", "Opened: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
}
fun play() {
@@ -1167,7 +1382,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
fun setPlaybackSpeed(speed: Float) {
val clampedSpeed = speed.coerceIn(0.25f, 4f)
exoPlayer?.setPlaybackSpeed(clampedSpeed)
updateTunnelingState()
updateTunnelingState("speed changed")
delegate?.onPropertyChange("speed", speed.toDouble())
}
@@ -1184,25 +1399,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
if (trackIndex >= 0 && trackIndex < audioGroups.size) {
val group = audioGroups[trackIndex]
// Pre-evaluate the new track's codec for tunneling before applying the override,
// so tunneling state is set correctly in the same parameter update.
val format = group.mediaTrackGroup.getFormat(0)
val mimeType = format.sampleMimeType
if (mimeType != null) {
tunnelingDisabledForAudioCodec = !hasHardwareAudioDecoder(mimeType)
Log.i(TAG, "Audio track switch to ${format.codecs} ($mimeType): tunneling ${if (tunnelingDisabledForAudioCodec) "DISABLED" else "enabled"}")
}
val currentSpeed = player.playbackParameters.speed
val shouldTunnel = tunnelingUserEnabled && (currentSpeed == 1f) && !tunnelingDisabledForCodec
selector.parameters = selector.buildUponParameters()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setTunnelingEnabled(shouldTunnel)
.build()
selectedAudioTrackId = trackId
delegate?.onPropertyChange("aid", trackId)
}
}
@@ -1213,6 +1415,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (trackId == null || trackId == "no") {
// Disable subtitles
selectedExternalSubtitleIndex = null
selectedSubtitleTrackId = "no"
selector.parameters = selector.buildUponParameters()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
@@ -1225,6 +1429,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val index = trackId.removePrefix("ext_sub_").toIntOrNull() ?: return
if (index >= 0 && index < externalSubtitles.size) {
// Reload media with selected external subtitle
selectedExternalSubtitleIndex = index
selectedSubtitleTrackId = trackId
reloadWithExternalSubtitle(index)
return
}
@@ -1239,6 +1445,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
if (trackIndex >= 0 && trackIndex < textGroups.size) {
val group = textGroups[trackIndex]
selectedExternalSubtitleIndex = null
selectedSubtitleTrackId = trackId
selector.parameters = selector.buildUponParameters()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
@@ -1253,22 +1461,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val player = exoPlayer ?: return
val currentPosition = player.currentPosition
val wasPlaying = player.isPlaying
val mediaItemBuilder = MediaItem.Builder()
.setUri(uri)
// Add the selected external subtitle
if (subtitleIndex >= 0 && subtitleIndex < externalSubtitles.size) {
val subtitle = externalSubtitles[subtitleIndex]
mediaItemBuilder.setSubtitleConfigurations(listOf(subtitle))
}
val mediaItem = mediaItemBuilder.build()
val shouldResume = player.playWhenReady
selectedExternalSubtitleIndex = subtitleIndex
selectedSubtitleTrackId = "ext_sub_$subtitleIndex"
val mediaItem = buildMediaItem(uri)
player.setMediaItem(mediaItem, currentPosition)
player.prepare()
player.playWhenReady = wasPlaying
player.playWhenReady = shouldResume
delegate?.onPropertyChange("sid", "ext_sub_$subtitleIndex")
}
@@ -1304,6 +1504,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
fun setVisible(visible: Boolean) {
if (disposing) return
currentVisible = visible
activity.runOnUiThread {
if (disposing) return@runOnUiThread
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
@@ -1321,6 +1522,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
bgOpacity: Int,
subtitlePosition: Int = 100
) {
subtitleStyleState = SubtitleStyleState(
fontSize = fontSize,
textColor = textColor,
borderSize = borderSize,
borderColor = borderColor,
bgColor = bgColor,
bgOpacity = bgOpacity,
subtitlePosition = subtitlePosition,
)
activity.runOnUiThread {
// 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
val fgColor = Color.parseColor(textColor)
@@ -1508,7 +1718,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
tunnelingDisabledForAudioCodec = false
tunnelingDisabledForVideoCodec = false
currentTunneledPlayback = false
tunnelingCorrectionInProgress = false
pendingRestoredAudioTrackId = null
pendingRestoredSubtitleTrackId = null
lastTunnelingCorrectionTarget = null
lastTunnelingCorrectionAtMs = 0L
pendingStartPositionMs = 0L
currentMediaIsLive = false
currentVisible = false
selectedAudioTrackId = null
selectedSubtitleTrackId = null
selectedExternalSubtitleIndex = null
reapplySubtitleStyleOnNextReady = false
exoPlayer?.clearVideoSurface()
exoPlayer?.removeListener(this)
exoPlayer?.release()