Skip to content

Commit

Permalink
支持选择内嵌字幕语言, close #98
Browse files Browse the repository at this point in the history
  • Loading branch information
Him188 committed May 19, 2024
1 parent 69087aa commit f95f8d2
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 18 deletions.
2 changes: 2 additions & 0 deletions app/shared/pages/episode-play/common/EpisodeVideo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,6 +204,7 @@ internal fun EpisodeVideoImpl(
},
danmakuEditor = danmakuEditor,
endActions = {
PlayerControllerDefaults.SubtitleSwitcher(playerState.subtitleTracks)
val speed by playerState.playbackSpeed.collectAsStateWithLifecycle()
SpeedSwitcher(
speed,
Expand Down
91 changes: 90 additions & 1 deletion app/shared/video-player/android/PlayerState.android.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<out Array<IntArray>>,
params: Parameters,
selectedAudioLanguage: String?
): Pair<ExoTrackSelection.Definition, Int>? {
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}")
Expand Down Expand Up @@ -185,6 +265,8 @@ internal class ExoPlayerState @UiThread constructor(
player.seekTo(positionMillis)
}

override val subtitleTracks: MutableTrackGroup<SubtitleTrack> = MutableTrackGroup()

override val currentPositionMillis: MutableStateFlow<Long> = MutableStateFlow(0)
override fun getExactCurrentPositionMillis(): Long = player.currentPosition

Expand All @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions app/shared/video-player/android/ui/VideoScaffold.android.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -196,6 +197,7 @@ private fun PreviewVideoScaffoldImpl(
}
},
endActions = {
PlayerControllerDefaults.SubtitleSwitcher(playerState.subtitleTracks)
val speed by playerState.playbackSpeed.collectAsStateWithLifecycle()
SpeedSwitcher(
speed,
Expand Down
51 changes: 36 additions & 15 deletions app/shared/video-player/common/ui/progress/PlayerControllerBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ 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
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
Expand Down Expand Up @@ -276,21 +276,43 @@ object PlayerControllerDefaults {
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
optionsProvider: () -> List<Float> = { 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 <T> OptionsSwitcher(
value: T,
onValueChange: (T) -> Unit,
optionsProvider: () -> List<T>,
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) }
TextButton(
{ 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 },
Expand All @@ -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
Expand Down Expand Up @@ -411,4 +432,4 @@ fun PlayerControllerBar(
}
}
}
}
}
22 changes: 22 additions & 0 deletions app/shared/video-player/common/ui/progress/SubtitleLanguage.kt
Original file line number Diff line number Diff line change
@@ -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")
}

99 changes: 99 additions & 0 deletions app/shared/video-player/common/ui/progress/SubtitleSwitcher.kt
Original file line number Diff line number Diff line change
@@ -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<SubtitleTrack?>,
candidates: Flow<List<SubtitleTrack>>,
) : 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<SubtitleTrack>,
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<SubtitlePresentation>,
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,
)
}
Loading

0 comments on commit f95f8d2

Please sign in to comment.