Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Progress track to simplify Slider integration #240

Merged
merged 4 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 114 additions & 0 deletions pillarbox-ui/src/main/java/ch/srgssr/pillarbox/ui/ProgressTracker.kt
Original file line number Diff line number Diff line change
@@ -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<Float> = player.currentPositionAsFlow().map { player.currentPositionPercentage() }
private val userSeekState = MutableStateFlow<UserSeekState>(UserSeekState.Idle)
private val canSeek = player.availableCommandsAsFlow().map { it.canSeek() }
private val progressPercentFlow: Flow<Float> = 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<Float> = progressPercentFlow.collectAsState(initial = player.currentPositionPercentage())

/**
* Can seek
*
* @return can seek as State.
*/
@Composable
fun canSeek(): State<Boolean> = 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
}