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.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale 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.media3.ui.compose.state.rememberPreviousButtonState
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.preferences.UserPreferences 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.components.LoadingPage
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -62,6 +70,7 @@ fun PlaybackContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(), viewModel: PlaybackViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) { LaunchedEffect(destination.itemId) {
viewModel.init(destination, deviceProfile, preferences) viewModel.init(destination, deviceProfile, preferences)
@ -76,6 +85,26 @@ fun PlaybackContent(
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf()) val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
val trickplay by viewModel.trickplay.observeAsState(null) val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf()) 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) { if (stream == null) {
// TODO loading // TODO loading
@ -234,14 +263,35 @@ fun PlaybackContent(
scale = contentScale, scale = contentScale,
playbackSpeed = playbackSpeed, playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()), moreButtonOptions = MoreButtonOptions(mapOf()),
subtitleIndex = null, subtitleIndex = currentPlayback?.subtitleIndex,
audioIndex = null, audioIndex = currentPlayback?.audioIndex,
audioStreams = audioStreams, audioStreams = audioStreams,
trickplayInfo = trickplay, trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl, trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters, 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -58,6 +57,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.AppColors 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.seekBack
import com.github.damontecres.dolphin.ui.seekForward import com.github.damontecres.dolphin.ui.seekForward
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
@ -349,18 +349,25 @@ fun RightPlaybackButtons(
) )
} }
if (showCaptionDialog) { if (showCaptionDialog) {
val context = LocalContext.current
val options = subtitleStreams.map { it.displayName } val options = subtitleStreams.map { it.displayName }
Timber.v("subtitleIndex=$subtitleIndex, options=$options") Timber.v("subtitleIndex=$subtitleIndex, options=$options")
val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex }?.plus(1) ?: 0
BottomDialog( BottomDialog(
choices = options, choices = listOf("None") + options,
currentChoice = subtitleIndex, currentChoice = currentChoice,
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showCaptionDialog = false showCaptionDialog = false
}, },
onSelectChoice = { index, _ -> 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, gravity = Gravity.END,
) )
@ -387,7 +394,7 @@ fun RightPlaybackButtons(
if (showAudioDialog) { if (showAudioDialog) {
BottomDialog( BottomDialog(
choices = audioStreams.map { it.displayName }, choices = audioStreams.map { it.displayName },
currentChoice = audioIndex, currentChoice = audioStreams.indexOfFirstOrNull { it.index == audioIndex },
onDismissRequest = { onDismissRequest = {
onControllerInteraction.invoke() onControllerInteraction.invoke()
showAudioDialog = false showAudioDialog = false

View file

@ -1,12 +1,18 @@
package com.github.damontecres.dolphin.ui.playback package com.github.damontecres.dolphin.ui.playback
import android.content.Context import android.content.Context
import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem 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 androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.Chapter import com.github.damontecres.dolphin.data.model.Chapter
import com.github.damontecres.dolphin.preferences.UserPreferences 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile 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.MediaStreamType
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
@ -157,6 +164,7 @@ class PlaybackViewModel
audioStreams.firstOrNull { it.language == "eng" }?.index audioStreams.firstOrNull { it.language == "eng" }?.index
?: audioStreams.firstOrNull()?.index ?: audioStreams.firstOrNull()?.index
Timber.v("base.mediaStreams=${base.mediaStreams}")
Timber.v("subtitleTracks=$subtitleStreams") Timber.v("subtitleTracks=$subtitleStreams")
Timber.v("audioStreams=$audioStreams") Timber.v("audioStreams=$audioStreams")
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
@ -183,6 +191,7 @@ class PlaybackViewModel
} }
} }
@OptIn(UnstableApi::class)
private suspend fun changeStreams( private suspend fun changeStreams(
itemId: UUID, itemId: UUID,
audioIndex: Int?, audioIndex: Int?,
@ -256,6 +265,24 @@ class PlaybackViewModel
decision.mediaItem, decision.mediaItem,
positionMs, 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 = val trickPlayInfo =
dto.trickplay dto.trickplay
@ -312,3 +339,77 @@ data class CurrentPlayback(
val subtitleIndex: Int?, val subtitleIndex: Int?,
val mediaSourceId: UUID?, 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 var currentPlaylistIndex = -2
override fun onCues(cueGroup: CueGroup) { 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 } val subtitles = cueGroup.cues.ifEmpty { null }
} }