diff --git a/app/shared/pages/episode-play/common/EpisodeVideo.kt b/app/shared/pages/episode-play/common/EpisodeVideo.kt index 7afc528a54..8328aeced2 100644 --- a/app/shared/pages/episode-play/common/EpisodeVideo.kt +++ b/app/shared/pages/episode-play/common/EpisodeVideo.kt @@ -43,6 +43,7 @@ import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults.SpeedSwitcher import me.him188.ani.app.videoplayer.ui.progress.ProgressIndicator import me.him188.ani.app.videoplayer.ui.progress.ProgressSlider +import me.him188.ani.app.videoplayer.ui.progress.SubtitleSwitcher import me.him188.ani.app.videoplayer.ui.progress.rememberProgressSliderState import me.him188.ani.app.videoplayer.ui.state.PlayerState import me.him188.ani.app.videoplayer.ui.state.togglePause @@ -203,6 +204,7 @@ internal fun EpisodeVideoImpl( }, danmakuEditor = danmakuEditor, endActions = { + PlayerControllerDefaults.SubtitleSwitcher(playerState.subtitleTracks) val speed by playerState.playbackSpeed.collectAsStateWithLifecycle() SpeedSwitcher( speed, diff --git a/app/shared/video-player/android/PlayerState.android.kt b/app/shared/video-player/android/PlayerState.android.kt index 1f171cb791..24972911b1 100644 --- a/app/shared/video-player/android/PlayerState.android.kt +++ b/app/shared/video-player/android/PlayerState.android.kt @@ -1,16 +1,22 @@ package me.him188.ani.app.videoplayer +import android.util.Pair import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.annotation.UiThread +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.ExoTrackSelection import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -27,9 +33,12 @@ import me.him188.ani.app.videoplayer.data.VideoProperties import me.him188.ani.app.videoplayer.data.VideoSource import me.him188.ani.app.videoplayer.media.VideoDataDataSource import me.him188.ani.app.videoplayer.ui.state.AbstractPlayerState +import me.him188.ani.app.videoplayer.ui.state.Label +import me.him188.ani.app.videoplayer.ui.state.MutableTrackGroup import me.him188.ani.app.videoplayer.ui.state.PlaybackState import me.him188.ani.app.videoplayer.ui.state.PlayerState import me.him188.ani.app.videoplayer.ui.state.PlayerStateFactory +import me.him188.ani.app.videoplayer.ui.state.SubtitleTrack import me.him188.ani.utils.logging.error import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds @@ -93,9 +102,80 @@ internal class ExoPlayerState @UiThread constructor( private val updateVideoPropertiesTasker = MonoTasker(backgroundScope) val player = kotlin.run { - ExoPlayer.Builder(context).apply {}.build().apply { + ExoPlayer.Builder(context).apply { + setTrackSelector(object : DefaultTrackSelector(context) { + override fun selectTextTrack( + mappedTrackInfo: MappedTrackInfo, + rendererFormatSupports: Array>, + params: Parameters, + selectedAudioLanguage: String? + ): Pair? { + val preferred = subtitleTracks.current.value + ?: return super.selectTextTrack( + mappedTrackInfo, + rendererFormatSupports, + params, + selectedAudioLanguage + ) + + infix fun SubtitleTrack.matches(group: TrackGroup): Boolean { + if (this.labels.isEmpty()) return false + for (index in 0 until group.length) { + val format = group.getFormat(index) + if (format.labels.isEmpty()) { + continue + } + if (this.labels.any { it.value == format.labels.first().value }) { + return true + } + } + return false + } + + // 备注: 这个实现可能并不好, 他只是恰好能跑 + for (rendererIndex in 0 until mappedTrackInfo.rendererCount) { + if (C.TRACK_TYPE_TEXT != mappedTrackInfo.getRendererType(rendererIndex)) continue + + val groups = mappedTrackInfo.getTrackGroups(rendererIndex) + for (groupIndex in 0 until groups.length) { + val trackGroup = groups[groupIndex] + if (preferred matches trackGroup) { + return Pair( + ExoTrackSelection.Definition( + trackGroup, + trackGroup.length - 1, // 如果选择所有字幕会闪烁 + ), + rendererIndex + ) + } + } + } + return super.selectTextTrack(mappedTrackInfo, rendererFormatSupports, params, selectedAudioLanguage) + } + }) + }.build().apply { playWhenReady = true addListener(object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + subtitleTracks.candidates.value = + tracks.groups.asSequence() + .filter { it.type == C.TRACK_TYPE_TEXT } + .flatMapIndexed { groupIndex: Int, group: Tracks.Group -> + sequence { + repeat(group.length) { index -> + val format = group.getTrackFormat(index) + yield( + SubtitleTrack( + "${openResource.value?.videoData?.filename}-$groupIndex-$index", + format.language, + format.labels.map { Label(it.language, it.value) }) + ) + } + } + } + .toList() + } + override fun onPlayerError(error: PlaybackException) { state.value = PlaybackState.ERROR logger.warn("ExoPlayer error: ${error.errorCodeName}") @@ -185,6 +265,8 @@ internal class ExoPlayerState @UiThread constructor( player.seekTo(positionMillis) } + override val subtitleTracks: MutableTrackGroup = MutableTrackGroup() + override val currentPositionMillis: MutableStateFlow = MutableStateFlow(0) override fun getExactCurrentPositionMillis(): Long = player.currentPosition @@ -196,6 +278,13 @@ internal class ExoPlayerState @UiThread constructor( delay(0.1.seconds) // 100 fps } } + backgroundScope.launch(Dispatchers.Main) { + subtitleTracks.current.collect { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().apply { + setPreferredTextLanguage(it?.labels?.first()?.value ?: it?.language) + }.build() + } + } } override fun pause() { diff --git a/app/shared/video-player/android/ui/VideoScaffold.android.kt b/app/shared/video-player/android/ui/VideoScaffold.android.kt index 0c915f8ee3..78f0ec81f7 100644 --- a/app/shared/video-player/android/ui/VideoScaffold.android.kt +++ b/app/shared/video-player/android/ui/VideoScaffold.android.kt @@ -39,6 +39,7 @@ import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults import me.him188.ani.app.videoplayer.ui.progress.PlayerControllerDefaults.SpeedSwitcher import me.him188.ani.app.videoplayer.ui.progress.ProgressIndicator import me.him188.ani.app.videoplayer.ui.progress.ProgressSlider +import me.him188.ani.app.videoplayer.ui.progress.SubtitleSwitcher import me.him188.ani.app.videoplayer.ui.progress.rememberProgressSliderState import me.him188.ani.app.videoplayer.ui.state.DummyPlayerState import me.him188.ani.app.videoplayer.ui.state.togglePause @@ -196,6 +197,7 @@ private fun PreviewVideoScaffoldImpl( } }, endActions = { + PlayerControllerDefaults.SubtitleSwitcher(playerState.subtitleTracks) val speed by playerState.playbackSpeed.collectAsStateWithLifecycle() SpeedSwitcher( speed, diff --git a/app/shared/video-player/common/ui/progress/PlayerControllerBar.kt b/app/shared/video-player/common/ui/progress/PlayerControllerBar.kt index c8204653a7..ef879e7815 100644 --- a/app/shared/video-player/common/ui/progress/PlayerControllerBar.kt +++ b/app/shared/video-player/common/ui/progress/PlayerControllerBar.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.ProvideTextStyle @@ -36,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -276,8 +276,30 @@ object PlayerControllerDefaults { value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, - style: TextStyle = LocalTextStyle.current, optionsProvider: () -> List = { listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 3f) }, + ) { + return OptionsSwitcher( + value = value, + onValueChange = onValueChange, + optionsProvider = optionsProvider, + renderValue = { Text(remember(it) { "${it}x" }) }, + renderValueExposed = { Text(remember(it) { if (it == 1.0f) "倍速" else """${it}x""" }) }, + modifier, + ) + } + + /** + * @param optionsProvider The options to choose from. Note that when the value changes, it will not reflect in the UI. + */ + @Composable + fun OptionsSwitcher( + value: T, + onValueChange: (T) -> Unit, + optionsProvider: () -> List, + renderValue: @Composable (T) -> Unit, + renderValueExposed: @Composable (T) -> Unit = renderValue, + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Box(modifier, contentAlignment = Alignment.Center) { var expanded by rememberSaveable { mutableStateOf(false) } @@ -285,12 +307,12 @@ object PlayerControllerDefaults { { expanded = true }, colors = ButtonDefaults.textButtonColors( contentColor = LocalContentColor.current - ) + ), + enabled = enabled ) { - Text(remember(value) { if (value == 1.0f) "倍速" else """${value}x""" }, style = style) + renderValueExposed(value) } - // TODO: Replace SpeedSwitcher dropdown with a side sheet in the future DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, @@ -299,15 +321,14 @@ object PlayerControllerDefaults { for (option in options) { DropdownMenuItem( text = { - Text( - remember(option) { "${option}x" }, - color = - if (value == option) { - MaterialTheme.colorScheme.primary - } else { - LocalContentColor.current - } - ) + val color = if (value == option) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + } + CompositionLocalProvider(LocalContentColor provides color) { + renderValue(option) + } }, onClick = { expanded = false @@ -411,4 +432,4 @@ fun PlayerControllerBar( } } } -} +} \ No newline at end of file diff --git a/app/shared/video-player/common/ui/progress/SubtitleLanguage.kt b/app/shared/video-player/common/ui/progress/SubtitleLanguage.kt new file mode 100644 index 0000000000..45731def0f --- /dev/null +++ b/app/shared/video-player/common/ui/progress/SubtitleLanguage.kt @@ -0,0 +1,22 @@ +package me.him188.ani.app.videoplayer.ui.progress + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import me.him188.ani.app.videoplayer.ui.state.SubtitleTrack +import me.him188.ani.datasources.api.topic.SubtitleLanguage + +@Immutable +class SubtitlePresentation( + val subtitleTrack: SubtitleTrack, + val displayName: String, +) + +@Stable +val SubtitleTrack.subtitleLanguage: SubtitleLanguage + get() { + for (label in labels) { + SubtitleLanguage.tryParse(label.value)?.let { return it } + } + return SubtitleLanguage.Other(labels.firstOrNull()?.value ?: language ?: "Unknown") + } + diff --git a/app/shared/video-player/common/ui/progress/SubtitleSwitcher.kt b/app/shared/video-player/common/ui/progress/SubtitleSwitcher.kt new file mode 100644 index 0000000000..cb6db34d4b --- /dev/null +++ b/app/shared/video-player/common/ui/progress/SubtitleSwitcher.kt @@ -0,0 +1,99 @@ +package me.him188.ani.app.videoplayer.ui.progress + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.app.videoplayer.ui.state.SubtitleTrack +import me.him188.ani.app.videoplayer.ui.state.TrackGroup +import moe.tlaster.precompose.flow.collectAsStateWithLifecycle + +@Stable +class SubtitleTrackState( + current: StateFlow, + candidates: Flow>, +) : AbstractViewModel() { + val options = candidates.map { tracks -> + tracks.map { track -> + SubtitlePresentation(track, track.subtitleLanguage.displayName) + } + }.flowOn(Dispatchers.Default).shareInBackground() + + val value = combine(options, current) { options, current -> + options.firstOrNull { it.subtitleTrack.id == current?.id } + }.flowOn(Dispatchers.Default) +} + + +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + playerState: TrackGroup, + modifier: Modifier = Modifier, + onSelect: (SubtitleTrack?) -> Unit = { playerState.select(it) }, +) { + val state = remember(playerState) { + SubtitleTrackState(playerState.current, playerState.candidates) + } + SubtitleSwitcher(state, onSelect, modifier) +} + +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + state: SubtitleTrackState, + onSelect: (SubtitleTrack?) -> Unit, + modifier: Modifier = Modifier, +) { + val options by state.options.collectAsStateWithLifecycle(emptyList()) + SubtitleSwitcher( + value = state.value.collectAsStateWithLifecycle(null).value, + onValueChange = { onSelect(it?.subtitleTrack) }, + optionsProvider = { options }, + modifier, + ) +} + +/** + * 选字幕 + */ +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + value: SubtitlePresentation?, + onValueChange: (SubtitlePresentation?) -> Unit, + optionsProvider: () -> List, + modifier: Modifier = Modifier, +) { + val optionsProviderUpdated by rememberUpdatedState(optionsProvider) + val options by remember { + derivedStateOf { + optionsProviderUpdated() + null + } + } + if (options.size <= 1) return // 1 for `null` + return OptionsSwitcher( + value = value, + onValueChange = onValueChange, + optionsProvider = { options }, + renderValue = { + if (it == null) { + Text("自动") + } else { + Text(it.displayName) + } + }, + renderValueExposed = { + Text(remember(it) { it?.displayName ?: "字幕" }) + }, + modifier, + ) +} diff --git a/app/shared/video-player/common/ui/state/PlayerState.kt b/app/shared/video-player/common/ui/state/PlayerState.kt index bc18fa9683..0841f9314a 100644 --- a/app/shared/video-player/common/ui/state/PlayerState.kt +++ b/app/shared/video-player/common/ui/state/PlayerState.kt @@ -123,6 +123,8 @@ interface PlayerState { */ @UiThread fun seekTo(positionMillis: Long) + + val subtitleTracks: TrackGroup } fun PlayerState.togglePause() { @@ -350,4 +352,6 @@ class DummyPlayerState : AbstractPlayerState(EmptyCoro override fun seekTo(positionMillis: Long) { this.currentPositionMillis.value = positionMillis } + + override val subtitleTracks: TrackGroup = emptyTrackGroup() } \ No newline at end of file diff --git a/app/shared/video-player/common/ui/state/TrackGroup.kt b/app/shared/video-player/common/ui/state/TrackGroup.kt new file mode 100644 index 0000000000..de61da242c --- /dev/null +++ b/app/shared/video-player/common/ui/state/TrackGroup.kt @@ -0,0 +1,58 @@ +package me.him188.ani.app.videoplayer.ui.state + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +@Stable +interface TrackGroup { + val current: StateFlow + + val candidates: Flow> + + fun select(track: T?): Boolean +} + +fun emptyTrackGroup(): TrackGroup = object : TrackGroup { + override val current: StateFlow = MutableStateFlow(null) + override val candidates: Flow> = emptyFlow() + + override fun select(track: T?): Boolean = false +} + +@Immutable +interface Track + +@Immutable +data class SubtitleTrack( + val id: String, + val language: String?, // "zh", 注意, 这可能不是实际字母语言 + val labels: List