mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Show/change subtitles & change audio tracks
This commit is contained in:
parent
f3df36e1a1
commit
9cc2fc40d3
4 changed files with 166 additions and 13 deletions
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue