Show/change subtitles & change audio tracks

This commit is contained in:
Damontecres 2025-10-04 18:10:16 -04:00
parent f3df36e1a1
commit 9cc2fc40d3
No known key found for this signature in database
4 changed files with 166 additions and 13 deletions

View file

@ -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<List<Cue>>(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),
)
}
}
}
}

View file

@ -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

View file

@ -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.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(subtitleIndex + 1)
}
Timber.v("Chosen subtitle track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
} else {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
}
if (audioIndex != null) {
val chosenTrack =
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(audioIndex + 1)
}
Timber.v("Chosen audio track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
}
}
}

View file

@ -17,11 +17,6 @@ class PlaybackListener : Player.Listener {
var currentPlaylistIndex = -2
override fun onCues(cueGroup: CueGroup) {
// val cues =
// cueGroup.cues
// .mapNotNull { it.text }
// .joinToString("\n")
// Log.v(TAG, "onCues: \n$cues")
val subtitles = cueGroup.cues.ifEmpty { null }
}