From 9cc2fc40d327d09a03fa160a01368a28a01f0abb Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 4 Oct 2025 18:10:16 -0400 Subject: [PATCH] Show/change subtitles & change audio tracks --- .../dolphin/ui/playback/PlaybackContent.kt | 54 +++++++++- .../dolphin/ui/playback/PlaybackControls.kt | 19 ++-- .../dolphin/ui/playback/PlaybackViewModel.kt | 101 ++++++++++++++++++ .../dolphin/util/profile/PlaybackListener.kt | 5 - 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 0a3bb578..a2184bba 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -33,9 +34,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.media3.common.Player +import androidx.media3.common.text.Cue +import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.modifiers.resizeWithContentScale @@ -45,6 +52,7 @@ import androidx.media3.ui.compose.state.rememberPresentationState import androidx.media3.ui.compose.state.rememberPreviousButtonState import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager @@ -62,6 +70,7 @@ fun PlaybackContent( modifier: Modifier = Modifier, viewModel: PlaybackViewModel = hiltViewModel(), ) { + val context = LocalContext.current val scope = rememberCoroutineScope() LaunchedEffect(destination.itemId) { viewModel.init(destination, deviceProfile, preferences) @@ -76,6 +85,26 @@ fun PlaybackContent( val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf()) val trickplay by viewModel.trickplay.observeAsState(null) val chapters by viewModel.chapters.observeAsState(listOf()) + val currentPlayback by viewModel.currentPlayback.observeAsState(null) + + var cues by remember { mutableStateOf>(listOf()) } + + // TODO move to viewmodel? + val cueListener = + remember { + object : Player.Listener { + override fun onCues(cueGroup: CueGroup) { + cues = cueGroup.cues + } + } + } + + OneTimeLaunchedEffect { + player.addListener(cueListener) + } + DisposableEffect(Unit) { + onDispose { player.removeListener(cueListener) } + } if (stream == null) { // TODO loading @@ -234,14 +263,35 @@ fun PlaybackContent( scale = contentScale, playbackSpeed = playbackSpeed, moreButtonOptions = MoreButtonOptions(mapOf()), - subtitleIndex = null, - audioIndex = null, + subtitleIndex = currentPlayback?.subtitleIndex, + audioIndex = currentPlayback?.audioIndex, audioStreams = audioStreams, trickplayInfo = trickplay, trickplayUrlFor = viewModel::getTrickplayUrl, chapters = chapters, ) } + + if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) { + AndroidView( + factory = { context -> + SubtitleView(context).apply { + setUserDefaultStyle() + setUserDefaultTextSize() + } + }, + update = { + it.setCues(cues) + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize() + .background(Color.Transparent), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 89377009..c613f765 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -58,6 +57,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.ui.AppColors +import com.github.damontecres.dolphin.ui.indexOfFirstOrNull import com.github.damontecres.dolphin.ui.seekBack import com.github.damontecres.dolphin.ui.seekForward import com.github.damontecres.dolphin.ui.tryRequestFocus @@ -349,18 +349,25 @@ fun RightPlaybackButtons( ) } if (showCaptionDialog) { - val context = LocalContext.current val options = subtitleStreams.map { it.displayName } Timber.v("subtitleIndex=$subtitleIndex, options=$options") + val currentChoice = + subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex }?.plus(1) ?: 0 BottomDialog( - choices = options, - currentChoice = subtitleIndex, + choices = listOf("None") + options, + currentChoice = currentChoice, onDismissRequest = { onControllerInteraction.invoke() showCaptionDialog = false }, onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) + val send = + if (index == 0) { + -1 + } else { + subtitleStreams[index - 1].index + } + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(send)) }, gravity = Gravity.END, ) @@ -387,7 +394,7 @@ fun RightPlaybackButtons( if (showAudioDialog) { BottomDialog( choices = audioStreams.map { it.displayName }, - currentChoice = audioIndex, + currentChoice = audioStreams.indexOfFirstOrNull { it.index == audioIndex }, onDismissRequest = { onControllerInteraction.invoke() showAudioDialog = false diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index d1ead291..33ed8bf8 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -1,12 +1,18 @@ package com.github.damontecres.dolphin.ui.playback import android.content.Context +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.github.damontecres.dolphin.data.model.Chapter import com.github.damontecres.dolphin.preferences.UserPreferences @@ -27,6 +33,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.TrickplayInfo @@ -157,6 +164,7 @@ class PlaybackViewModel audioStreams.firstOrNull { it.language == "eng" }?.index ?: audioStreams.firstOrNull()?.index + Timber.v("base.mediaStreams=${base.mediaStreams}") Timber.v("subtitleTracks=$subtitleStreams") Timber.v("audioStreams=$audioStreams") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") @@ -183,6 +191,7 @@ class PlaybackViewModel } } + @OptIn(UnstableApi::class) private suspend fun changeStreams( itemId: UUID, audioIndex: Int?, @@ -256,6 +265,24 @@ class PlaybackViewModel decision.mediaItem, positionMs, ) + if (audioIndex != null || subtitleIndex != null) { + val trackActivationListener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + Timber.v("onTracksChanged: $tracks") + if (tracks.groups.isNotEmpty()) { + applyTrackSelections( + player, + source, + audioIndex, + subtitleIndex, + ) + player.removeListener(this) + } + } + } + player.addListener(trackActivationListener) + } } val trickPlayInfo = dto.trickplay @@ -312,3 +339,77 @@ data class CurrentPlayback( val subtitleIndex: Int?, val mediaSourceId: UUID?, ) + +private val Format.idAsInt: Int? + @OptIn(UnstableApi::class) + get() = + id?.let { + if (it.contains(":")) { + it.split(":").last().toIntOrNull() + } else { + it.toIntOrNull() + } + } + +@OptIn(UnstableApi::class) +private fun applyTrackSelections( + player: Player, + source: MediaSourceInfo, + audioIndex: Int?, + subtitleIndex: Int?, +) { + if (source.supportsDirectPlay) { + if (subtitleIndex != null && subtitleIndex >= 0) { + val chosenTrack = + player.currentTracks.groups.firstOrNull { group -> + group.type == C.TRACK_TYPE_TEXT && group.isSupported && + (0.. + group.type == C.TRACK_TYPE_AUDIO && group.isSupported && + (0..