diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 267667fd..ebc56562 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -97,7 +97,7 @@ fun PlaybackControls( controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, showDebugInfo: Boolean, - onSeekProgress: (Float) -> Unit, + onSeekProgress: (Long) -> Unit, showPlay: Boolean, previousEnabled: Boolean, nextEnabled: Boolean, @@ -194,7 +194,7 @@ fun SeekBar( isEnabled: Boolean, intervals: Int, controllerViewState: ControllerViewState, - onSeekProgress: (Float) -> Unit, + onSeekProgress: (Long) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { @@ -213,17 +213,18 @@ fun SeekBar( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - SeekBarImpl( + IntervalSeekBarImpl( progress = progress, bufferedProgress = bufferedProgress, onSeek = { onSeekProgress(it) }, controllerViewState = controllerViewState, - intervals = intervals, +// intervals = intervals, modifier = Modifier.fillMaxWidth(), interactionSource = interactionSource, enabled = isEnabled, + durationMs = player.contentDuration, ) Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index 8f6ddda2..5e8645c9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -33,7 +33,7 @@ fun PlaybackOverlay( nextEnabled: Boolean, seekEnabled: Boolean, onPlaybackActionClick: (PlaybackAction) -> Unit, - onSeekBarChange: (Float) -> Unit, + onSeekBarChange: (Long) -> Unit, showDebugInfo: Boolean, scale: ContentScale, playbackSpeed: Float, @@ -48,8 +48,8 @@ fun PlaybackOverlay( seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { // Will be used for preview/trick play images - var seekProgressPercent by remember { mutableFloatStateOf(-1f) } - val seekProgressMs = seekProgressPercent * playerControls.duration + var seekProgressMs by remember { mutableLongStateOf(-1L) } + var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() Box( @@ -97,7 +97,7 @@ fun PlaybackOverlay( ), previewImageUrl = imageUrl, duration = playerControls.duration, - seekProgress = seekProgressPercent, + seekProgressMs = seekProgressMs, videoWidth = trickplayInfo.width, videoHeight = trickplayInfo.height, trickPlayInfo = trickplayInfo, @@ -114,7 +114,7 @@ fun PlaybackOverlay( controllerViewState = controllerViewState, showDebugInfo = showDebugInfo, onSeekProgress = { - seekProgressPercent = it + seekProgressMs = it onSeekBarChange(it) }, showPlay = showPlay, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt index ad711667..c3cc6791 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt @@ -18,6 +18,7 @@ package com.github.damontecres.dolphin.ui.playback * limitations under the License. */ +import android.view.KeyEvent import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.focusable @@ -30,8 +31,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,15 +42,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.ui.handleDPadKeyEvents +import kotlinx.coroutines.FlowPreview +import kotlin.time.Duration.Companion.seconds @Composable -fun SeekBarImpl( +fun SteppedSeekBarImpl( progress: Float, + durationMs: Long, bufferedProgress: Float, - onSeek: (seekProgress: Float) -> Unit, + onSeek: (Long) -> Unit, controllerViewState: ControllerViewState, modifier: Modifier = Modifier, intervals: Int = 10, @@ -55,10 +64,6 @@ fun SeekBarImpl( enabled: Boolean = true, ) { val isFocused by interactionSource.collectIsFocusedAsState() - val color = MaterialTheme.colorScheme.border - val animatedIndicatorHeight by animateDpAsState( - targetValue = 6.dp.times((if (isFocused) 2f else 1f)), - ) var hasSeeked by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(progress) } val progressToUse = if (isFocused && hasSeeked) seekProgress else progress @@ -68,25 +73,91 @@ fun SeekBarImpl( val offset = 1f / intervals - val handleSeekEventModifier = - Modifier.handleDPadKeyEvents( - onCenter = { - controllerViewState.pulseControls() - onSeek(seekProgress) - }, - onLeft = { - controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) - hasSeeked = true - onSeek(seekProgress) - }, - onRight = { - controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) - hasSeeked = true - onSeek(seekProgress) - }, - ) + val seek = { percent: Float -> + onSeek((percent * durationMs).toLong()) + } + + SeekBarDisplay( + enabled = enabled, + progress = progressToUse, + bufferedProgress = bufferedProgress, + onLeft = { + controllerViewState.pulseControls() + seekProgress = (progressToUse - offset).coerceAtLeast(0f) + hasSeeked = true + seek(seekProgress) + }, + onRight = { + controllerViewState.pulseControls() + seekProgress = (progressToUse + offset).coerceAtMost(1f) + hasSeeked = true + seek(seekProgress) + }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@OptIn(FlowPreview::class) +@Composable +fun IntervalSeekBarImpl( + progress: Float, + durationMs: Long, + bufferedProgress: Float, + onSeek: (Long) -> Unit, + controllerViewState: ControllerViewState, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + var hasSeeked by remember { mutableStateOf(false) } + var seekPositionMs by remember { mutableLongStateOf((progress * durationMs).toLong()) } + val progressToUse by remember { derivedStateOf { if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong() } } + + LaunchedEffect(isFocused) { + if (!isFocused) hasSeeked = false + } + + val offset = 30.seconds.inWholeMilliseconds + + SeekBarDisplay( + enabled = enabled, + progress = (progressToUse.toDouble() / durationMs).toFloat(), + bufferedProgress = bufferedProgress, + onLeft = { + controllerViewState.pulseControls() + seekPositionMs = (progressToUse - offset).coerceAtLeast(0L) + hasSeeked = true + onSeek(seekPositionMs) + }, + onRight = { + controllerViewState.pulseControls() + seekPositionMs = (progressToUse + offset).coerceAtMost(durationMs) + hasSeeked = true + onSeek(seekPositionMs) + }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun SeekBarDisplay( + progress: Float, + bufferedProgress: Float, + onLeft: () -> Unit, + onRight: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val color = MaterialTheme.colorScheme.border + + val isFocused by interactionSource.collectIsFocusedAsState() + val animatedIndicatorHeight by animateDpAsState( + targetValue = 6.dp.times((if (isFocused) 2f else 1f)), + ) Column( modifier = modifier, @@ -98,8 +169,25 @@ fun SeekBarImpl( .fillMaxWidth() .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) - .then(handleSeekEventModifier) - .focusable(enabled = enabled, interactionSource = interactionSource), + .onPreviewKeyEvent { event -> + val trigger = + event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 + when (event.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { + if (trigger) onLeft.invoke() + return@onPreviewKeyEvent true + } + + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { + if (trigger) onRight.invoke() + return@onPreviewKeyEvent true + } + } + false + }.handleDPadKeyEvents( + onLeft = onLeft, + onRight = onRight, + ).focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( @@ -126,7 +214,7 @@ fun SeekBarImpl( end = Offset( // x = size.width.times(if (isSelected) seekProgress else progress), - x = size.width.times(progressToUse), + x = size.width.times(progress), y = yOffset, ), strokeWidth = size.height, @@ -135,7 +223,7 @@ fun SeekBarImpl( drawCircle( color = Color.White, radius = size.height + 2, - center = Offset(x = size.width.times(progressToUse), y = yOffset), + center = Offset(x = size.width.times(progress), y = yOffset), ) }, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt index 5f3e44ed..85302116 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt @@ -7,14 +7,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.media3.common.Player -import androidx.media3.common.listen import androidx.media3.common.util.UnstableApi import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.debounce @UnstableApi @Composable @@ -37,23 +36,26 @@ class SeekBarState( var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)) private set - private var job: Job? = null + private val channel = Channel(CONFLATED) - fun onValueChange(progress: Float) { - job?.cancel() - job = - scope.launch(Dispatchers.IO) { - delay(750L) - withContext(Dispatchers.Main) { - player.seekTo((player.duration * progress).toLong()) - } + fun onValueChange(positionMs: Long) { + channel.trySend(positionMs) + } + + @OptIn(FlowPreview::class) + suspend fun observe() { + channel + .consumeAsFlow() + .debounce { 750L } + .collect { + player.seekTo(it) } } - suspend fun observe(): Nothing = - player.listen { events -> - if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) { - isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD) - } - } +// suspend fun observe(): Nothing = +// player.listen { events -> +// if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) { +// isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD) +// } +// } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt index 1c8d8884..a4354056 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt @@ -63,7 +63,7 @@ fun Modifier.offsetByPercent(xPercentage: Float) = fun SeekPreviewImage( previewImageUrl: String, duration: Long, - seekProgress: Float, + seekProgressMs: Long, videoWidth: Int?, videoHeight: Int?, trickPlayInfo: TrickplayInfo, @@ -86,7 +86,7 @@ fun SeekPreviewImage( val heightPx = with(LocalDensity.current) { height.toPx().toInt() } val widthPx = with(LocalDensity.current) { width.toPx().toInt() } - val index = (duration * seekProgress / trickPlayInfo.interval).toInt() // Which tile + val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth val imageIndex = index % numberOfTitlesPerImage @@ -118,7 +118,7 @@ fun SeekPreviewImage( ) } Text( - text = (seekProgress * duration / 1000).toLong().seconds.toString(), + text = (seekProgressMs / 1000L).seconds.toString(), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, )