diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt index 8f96d6466..bc36341bb 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/controls/PlayerTimeSlider.kt @@ -9,75 +9,39 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.media3.common.Player -import ch.srgssr.pillarbox.player.canSeek -import ch.srgssr.pillarbox.ui.availableCommandsAsState -import ch.srgssr.pillarbox.ui.currentPositionAsState -import ch.srgssr.pillarbox.ui.durationAsState +import ch.srgssr.pillarbox.ui.ProgressTracker +import ch.srgssr.pillarbox.ui.rememberProgressTracker /** * Player time slider * - * @param player The [StatefulPlayer] to observe. + * @param player The [Player] to observe. * @param modifier The modifier to be applied to the layout. + * @param sliderColors The slider colors to apply. + * @param progressTracker The progress track. * @param interactionSource The Slider interaction source. */ @Composable fun PlayerTimeSlider( player: Player, modifier: Modifier = Modifier, + sliderColors: SliderColors = playerCustomColors(), + progressTracker: ProgressTracker = rememberProgressTracker(player = player), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - TimeSlider( - modifier = modifier, - position = player.currentPositionAsState(), - duration = player.durationAsState(), - enabled = player.availableCommandsAsState().canSeek(), - interactionSource = interactionSource, - onSeek = { positionMs, finished -> - if (finished) { - player.seekTo(positionMs) - } - } - ) -} - -@Composable -private fun TimeSlider( - position: Long, - duration: Long, - modifier: Modifier = Modifier, - enabled: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onSeek: ((Long, Boolean) -> Unit)? = null, -) { - val progressPercentage = position / duration.coerceAtLeast(1).toFloat() - var sliderPosition by remember { mutableStateOf(0.0f) } - var isUserSeeking by remember { mutableStateOf(false) } - if (!isUserSeeking) { - sliderPosition = progressPercentage - } + val sliderPosition = progressTracker.progressPercent() Slider( - modifier = modifier, value = sliderPosition, + modifier = modifier, + value = sliderPosition.value, interactionSource = interactionSource, - onValueChange = { - isUserSeeking = true - sliderPosition = it - onSeek?.let { it1 -> it1((sliderPosition * duration).toLong(), false) } - }, - onValueChangeFinished = { - onSeek?.let { it((sliderPosition * duration).toLong(), true) } - isUserSeeking = false - }, - enabled = enabled, - colors = playerCustomColors(), + onValueChange = progressTracker::userSeek, + onValueChangeFinished = progressTracker::userSeekFinished, + enabled = progressTracker.canSeek().value, + colors = sliderColors, ) } @@ -89,9 +53,3 @@ private fun playerCustomColors(): SliderColors = SliderDefaults.colors( activeTrackColor = Color.White, thumbColor = Color.White ) - -@Preview(showBackground = false) -@Composable -fun TimeSliderPreview() { - TimeSlider(position = 34 * 3600 * 1000L, duration = 67 * 3600 * 1000L, enabled = true) -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt index 8aceaad15..fa4d74744 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt @@ -181,6 +181,15 @@ fun Player.getPlaybackSpeed(): Float { return playbackParameters.speed } +/** + * Current position percent + * + * @return the current position in percent [0,1]. + */ +fun Player.currentPositionPercentage(): Float { + return currentPosition / duration.coerceAtLeast(1).toFloat() +} + /** * Return if the playback [speed] is possible at [position]. * Always return true for none live content or if [Player.getCurrentTimeline] is empty. diff --git a/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ProgressTracker.kt b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ProgressTracker.kt new file mode 100644 index 000000000..508c9caff --- /dev/null +++ b/pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ProgressTracker.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023. SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.availableCommandsAsFlow +import ch.srgssr.pillarbox.player.canSeek +import ch.srgssr.pillarbox.player.currentPositionAsFlow +import ch.srgssr.pillarbox.player.currentPositionPercentage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** + * Progress tracker + * + * Handle a progress position that is a mix of the player current position and the user desired seek position. + * + * @property player The player whose current position must be tracked. + */ +@Stable +class ProgressTracker internal constructor(private val player: Player) { + private val playerProgressPercent: Flow = player.currentPositionAsFlow().map { player.currentPositionPercentage() } + private val userSeekState = MutableStateFlow(UserSeekState.Idle) + private val canSeek = player.availableCommandsAsFlow().map { it.canSeek() } + private val progressPercentFlow: Flow = combine(userSeekState, playerProgressPercent) { seekState, playerProgress -> + when (seekState) { + is UserSeekState.Seeking -> seekState.percent + else -> playerProgress + } + } + + /** + * Progress percent + * + * @return progress percent as State. + */ + @Composable + fun progressPercent(): State = progressPercentFlow.collectAsState(initial = player.currentPositionPercentage()) + + /** + * Can seek + * + * @return can seek as State. + */ + @Composable + fun canSeek(): State = canSeek.collectAsState(initial = player.availableCommands.canSeek()) + + /** + * User seek at percent position + * + * @param percent Position in percent [0,1]. + */ + fun userSeek(percent: Float) { + userSeekState.value = UserSeekState.Seeking(percent) + } + + /** + * User has finished seeking. + */ + fun userSeekFinished() { + userSeekState.value.let { + if (it is UserSeekState.Seeking) { + userSeekState.value = UserSeekState.End(it.percent) + } + } + } + + internal suspend fun handleSeek() { + userSeekState.collectLatest { + when (it) { + is UserSeekState.End -> { + player.seekTo((it.percent * player.duration).toLong()) + } + + else -> { + // Nothing + } + } + } + } + + private sealed interface UserSeekState { + data object Idle : UserSeekState + data class Seeking(val percent: Float) : UserSeekState + data class End(val percent: Float) : UserSeekState + } +} + +/** + * Remember progress tracker + * + * @param player The player to observe. + */ +@Composable +fun rememberProgressTracker(player: Player): ProgressTracker { + val progressTracker = remember(player) { + ProgressTracker(player) + } + LaunchedEffect(progressTracker) { + progressTracker.handleSeek() + } + return progressTracker +}