From d732654153dd1556b56bd8c5e12b2d693046c2fd Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 15:02:53 +0200 Subject: [PATCH] fix: player crash when switching audio language (#89) Reported by users: the player crashed (or showed an error) as soon as they picked a different audio language from the track menu during playback. Two audio-track selection call sites in PlayerScreen.kt built a `TrackSelectionOverride(group, trackIndex)` after only validating `track.groupIndex < trackGroups.size` and `group.type == TRACK_TYPE_AUDIO` \u2014 but not `trackIndex < group.length`. There was also no try/catch around `exoPlayer.trackSelectionParameters = params.build()`. Root cause: `AudioTrackInfo` captures `groupIndex` / `trackIndex` at the moment `onTracksChanged` fires. Between that moment and the user actually picking a track from the menu (which can be several seconds later), the player may have re-prepared due to: - adaptive stream switch - user switching source from the source picker - MediaItem rebuild when a new external subtitle is selected - automatic recovery from a transient network error After any of those events the track groups layout can change, leaving the cached `trackIndex` out of bounds for the new group. Media3's `TrackSelectionOverride` constructor throws `IllegalArgumentException` and the crash propagates up to the Composable, tearing down the player. Changes: - Extract the audio-track selection logic into a single private helper `applyAudioTrackSelection(exoPlayer, track, audioTracks)` that: - Wraps the whole operation in try/catch for IllegalArgumentException, IllegalStateException, and generic Exception. - Calls `clearOverridesOfType(C.TRACK_TYPE_AUDIO)` before setting the new override so stale overrides from prior selections don't pin the player to a no-longer-present track. - Validates `groupIndex in groups.indices`, `group.type == TRACK_TYPE_AUDIO`, AND `trackIndex in 0 until group.length` before calling `TrackSelectionOverride(...)`. - If the group/track validation fails but the override call site doesn't throw, still applies the `setPreferredAudioLanguage` hint so Media3 picks the closest matching track on its own rather than leaving the user on the wrong language. - Returns the resolved `selectedAudioIndex` or `null` on failure, so the caller can keep the previous selection rather than jumping to an incorrect one. - Replace both audio-selection call sites (the D-pad menu handler at ~line 1485 and the `onSelectAudio` callback at ~line 2075) with calls to the helper. Both sites shrank from ~20 lines to 3 lines. The subtitle-selection paths in the same file (lines 934, 983) already validate `groupIndex in groups.indices` and have been stable, so they are not touched in this PR to keep the diff focused on the reported crash. They can be migrated to a similar helper in a follow-up if wanted. Closes #89 --- .../tv/ui/screens/player/PlayerScreen.kt | 110 ++++++++++++------ 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index fdfe35b07..045dde31b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -106,6 +106,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.media3.common.C +import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.Player @@ -1483,24 +1484,10 @@ fun PlayerScreen( } else { // Audio selection audioTracks.getOrNull(subtitleMenuIndex)?.let { track -> - // Switch audio track via ExoPlayer - val params = exoPlayer.trackSelectionParameters.buildUpon() - params.setPreferredAudioLanguage(track.language) - val trackGroups = exoPlayer.currentTracks.groups - if (track.groupIndex < trackGroups.size && - trackGroups[track.groupIndex].type == C.TRACK_TYPE_AUDIO - ) { - params.setOverrideForType( - androidx.media3.common.TrackSelectionOverride( - trackGroups[track.groupIndex].mediaTrackGroup, - track.trackIndex - ) - ) + // Defensive track-selection — see applyAudioTrackSelection. + applyAudioTrackSelection(exoPlayer, track, audioTracks)?.let { + selectedAudioIndex = it } - exoPlayer.trackSelectionParameters = params.build() - selectedAudioIndex = audioTracks.indexOfFirst { - it.groupIndex == track.groupIndex && it.trackIndex == track.trackIndex - }.takeIf { it >= 0 } ?: track.index } } showSubtitleMenu = false @@ -2073,24 +2060,12 @@ fun PlayerScreen( } }, onSelectAudio = { track -> - // Switch audio track via ExoPlayer - val params = exoPlayer.trackSelectionParameters.buildUpon() - params.setPreferredAudioLanguage(track.language) - val trackGroups = exoPlayer.currentTracks.groups - if (track.groupIndex < trackGroups.size && - trackGroups[track.groupIndex].type == C.TRACK_TYPE_AUDIO - ) { - params.setOverrideForType( - androidx.media3.common.TrackSelectionOverride( - trackGroups[track.groupIndex].mediaTrackGroup, - track.trackIndex - ) - ) + // Defensive track-selection — validates group + track bounds and + // swallows IllegalArgumentException from stale indices after a + // player re-prepare. Fixes crash reported in issue #89. + applyAudioTrackSelection(exoPlayer, track, audioTracks)?.let { + selectedAudioIndex = it } - exoPlayer.trackSelectionParameters = params.build() - selectedAudioIndex = audioTracks.indexOfFirst { - it.groupIndex == track.groupIndex && it.trackIndex == track.trackIndex - }.takeIf { it >= 0 } ?: track.index showSubtitleMenu = false showControls = true // Restore focus to subtitle button after closing menu @@ -2479,6 +2454,73 @@ data class AudioTrackInfo( val codec: String? ) +/** + * Apply an audio-track selection to the player defensively. + * + * The stored [AudioTrackInfo] captures `groupIndex` / `trackIndex` at the moment the + * `onTracksChanged` listener fires. Between that moment and the user actually picking + * a track from the menu, the player may have re-prepared (e.g. adaptive stream switch, + * source reselection, MediaItem rebuild for a new external subtitle), and the current + * `exoPlayer.currentTracks.groups` layout may no longer match those indices. Calling + * `TrackSelectionOverride(group, trackIndex)` with a stale `trackIndex >= group.length` + * throws `IllegalArgumentException` inside Media3 and crashes the player. + * + * This helper wraps the selection in try/catch, validates every index before use, and + * clears any existing audio override before applying the new one so stale overrides + * from prior selections don't pin the player to a no-longer-present track. Fixes #89. + * + * @return the index in [audioTracks] that was actually applied, or `null` if the + * selection could not be applied (caller should leave the previous index). + */ +private fun applyAudioTrackSelection( + exoPlayer: ExoPlayer, + track: AudioTrackInfo, + audioTracks: List +): Int? { + return try { + val params = exoPlayer.trackSelectionParameters.buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .setPreferredAudioLanguage(track.language) + + val trackGroups = exoPlayer.currentTracks.groups + val groupInRange = track.groupIndex in trackGroups.indices + if (groupInRange) { + val group = trackGroups[track.groupIndex] + val isAudioGroup = group.type == C.TRACK_TYPE_AUDIO + val trackInRange = track.trackIndex in 0 until group.length + if (isAudioGroup && trackInRange) { + params.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + track.trackIndex + ) + ) + } + // If the group is stale we still fall through and apply the + // preferredAudioLanguage hint above — Media3 will pick the closest + // matching track on its own rather than crashing. + } + + exoPlayer.trackSelectionParameters = params.build() + + audioTracks.indexOfFirst { + it.groupIndex == track.groupIndex && it.trackIndex == track.trackIndex + }.takeIf { it >= 0 } ?: track.index + } catch (e: IllegalArgumentException) { + // Stale track/group index after a player re-prepare. Leave the current + // selection alone instead of crashing; user can retry the menu. + android.util.Log.w("PlayerScreen", "applyAudioTrackSelection rejected stale index: ${e.message}") + null + } catch (e: IllegalStateException) { + // Player released or in an invalid state. + android.util.Log.w("PlayerScreen", "applyAudioTrackSelection on invalid player: ${e.message}") + null + } catch (e: Exception) { + android.util.Log.e("PlayerScreen", "applyAudioTrackSelection unexpected error", e) + null + } +} + /** * Language code to full name mapping */