Support for different seek bar implementations

This commit is contained in:
Damontecres 2025-10-01 17:37:58 -04:00
parent 9c95685385
commit a7046a942e
No known key found for this signature in database
5 changed files with 154 additions and 63 deletions

View file

@ -97,7 +97,7 @@ fun PlaybackControls(
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean, showDebugInfo: Boolean,
onSeekProgress: (Float) -> Unit, onSeekProgress: (Long) -> Unit,
showPlay: Boolean, showPlay: Boolean,
previousEnabled: Boolean, previousEnabled: Boolean,
nextEnabled: Boolean, nextEnabled: Boolean,
@ -194,7 +194,7 @@ fun SeekBar(
isEnabled: Boolean, isEnabled: Boolean,
intervals: Int, intervals: Int,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
onSeekProgress: (Float) -> Unit, onSeekProgress: (Long) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
@ -213,17 +213,18 @@ fun SeekBar(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
SeekBarImpl( IntervalSeekBarImpl(
progress = progress, progress = progress,
bufferedProgress = bufferedProgress, bufferedProgress = bufferedProgress,
onSeek = { onSeek = {
onSeekProgress(it) onSeekProgress(it)
}, },
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
intervals = intervals, // intervals = intervals,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
interactionSource = interactionSource, interactionSource = interactionSource,
enabled = isEnabled, enabled = isEnabled,
durationMs = player.contentDuration,
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -33,7 +33,7 @@ fun PlaybackOverlay(
nextEnabled: Boolean, nextEnabled: Boolean,
seekEnabled: Boolean, seekEnabled: Boolean,
onPlaybackActionClick: (PlaybackAction) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit,
onSeekBarChange: (Float) -> Unit, onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean, showDebugInfo: Boolean,
scale: ContentScale, scale: ContentScale,
playbackSpeed: Float, playbackSpeed: Float,
@ -48,8 +48,8 @@ fun PlaybackOverlay(
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
// Will be used for preview/trick play images // Will be used for preview/trick play images
var seekProgressPercent by remember { mutableFloatStateOf(-1f) } var seekProgressMs by remember { mutableLongStateOf(-1L) }
val seekProgressMs = seekProgressPercent * playerControls.duration var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
Box( Box(
@ -97,7 +97,7 @@ fun PlaybackOverlay(
), ),
previewImageUrl = imageUrl, previewImageUrl = imageUrl,
duration = playerControls.duration, duration = playerControls.duration,
seekProgress = seekProgressPercent, seekProgressMs = seekProgressMs,
videoWidth = trickplayInfo.width, videoWidth = trickplayInfo.width,
videoHeight = trickplayInfo.height, videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo, trickPlayInfo = trickplayInfo,
@ -114,7 +114,7 @@ fun PlaybackOverlay(
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,
onSeekProgress = { onSeekProgress = {
seekProgressPercent = it seekProgressMs = it
onSeekBarChange(it) onSeekBarChange(it)
}, },
showPlay = showPlay, showPlay = showPlay,

View file

@ -18,6 +18,7 @@ package com.github.damontecres.dolphin.ui.playback
* limitations under the License. * limitations under the License.
*/ */
import android.view.KeyEvent
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -30,8 +31,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -39,15 +42,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap 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.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun SeekBarImpl( fun SteppedSeekBarImpl(
progress: Float, progress: Float,
durationMs: Long,
bufferedProgress: Float, bufferedProgress: Float,
onSeek: (seekProgress: Float) -> Unit, onSeek: (Long) -> Unit,
controllerViewState: ControllerViewState, controllerViewState: ControllerViewState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
intervals: Int = 10, intervals: Int = 10,
@ -55,10 +64,6 @@ fun SeekBarImpl(
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val isFocused by interactionSource.collectIsFocusedAsState() 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 hasSeeked by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(progress) } var seekProgress by remember { mutableFloatStateOf(progress) }
val progressToUse = if (isFocused && hasSeeked) seekProgress else progress val progressToUse = if (isFocused && hasSeeked) seekProgress else progress
@ -68,25 +73,91 @@ fun SeekBarImpl(
val offset = 1f / intervals val offset = 1f / intervals
val handleSeekEventModifier = val seek = { percent: Float ->
Modifier.handleDPadKeyEvents( onSeek((percent * durationMs).toLong())
onCenter = { }
controllerViewState.pulseControls()
onSeek(seekProgress) SeekBarDisplay(
}, enabled = enabled,
onLeft = { progress = progressToUse,
controllerViewState.pulseControls() bufferedProgress = bufferedProgress,
seekProgress = (progressToUse - offset).coerceAtLeast(0f) onLeft = {
hasSeeked = true controllerViewState.pulseControls()
onSeek(seekProgress) seekProgress = (progressToUse - offset).coerceAtLeast(0f)
}, hasSeeked = true
onRight = { seek(seekProgress)
controllerViewState.pulseControls() },
seekProgress = (progressToUse + offset).coerceAtMost(1f) onRight = {
hasSeeked = true controllerViewState.pulseControls()
onSeek(seekProgress) 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( Column(
modifier = modifier, modifier = modifier,
@ -98,8 +169,25 @@ fun SeekBarImpl(
.fillMaxWidth() .fillMaxWidth()
.height(animatedIndicatorHeight) .height(animatedIndicatorHeight)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.then(handleSeekEventModifier) .onPreviewKeyEvent { event ->
.focusable(enabled = enabled, interactionSource = interactionSource), 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 = { onDraw = {
val yOffset = size.height.div(2) val yOffset = size.height.div(2)
drawLine( drawLine(
@ -126,7 +214,7 @@ fun SeekBarImpl(
end = end =
Offset( Offset(
// x = size.width.times(if (isSelected) seekProgress else progress), // x = size.width.times(if (isSelected) seekProgress else progress),
x = size.width.times(progressToUse), x = size.width.times(progress),
y = yOffset, y = yOffset,
), ),
strokeWidth = size.height, strokeWidth = size.height,
@ -135,7 +223,7 @@ fun SeekBarImpl(
drawCircle( drawCircle(
color = Color.White, color = Color.White,
radius = size.height + 2, radius = size.height + 2,
center = Offset(x = size.width.times(progressToUse), y = yOffset), center = Offset(x = size.width.times(progress), y = yOffset),
) )
}, },
) )

View file

@ -7,14 +7,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.debounce
@UnstableApi @UnstableApi
@Composable @Composable
@ -37,23 +36,26 @@ class SeekBarState(
var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD)) var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD))
private set private set
private var job: Job? = null private val channel = Channel<Long>(CONFLATED)
fun onValueChange(progress: Float) { fun onValueChange(positionMs: Long) {
job?.cancel() channel.trySend(positionMs)
job = }
scope.launch(Dispatchers.IO) {
delay(750L) @OptIn(FlowPreview::class)
withContext(Dispatchers.Main) { suspend fun observe() {
player.seekTo((player.duration * progress).toLong()) channel
} .consumeAsFlow()
.debounce { 750L }
.collect {
player.seekTo(it)
} }
} }
suspend fun observe(): Nothing = // suspend fun observe(): Nothing =
player.listen { events -> // player.listen { events ->
if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) { // if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD) // isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD)
} // }
} // }
} }

View file

@ -63,7 +63,7 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
fun SeekPreviewImage( fun SeekPreviewImage(
previewImageUrl: String, previewImageUrl: String,
duration: Long, duration: Long,
seekProgress: Float, seekProgressMs: Long,
videoWidth: Int?, videoWidth: Int?,
videoHeight: Int?, videoHeight: Int?,
trickPlayInfo: TrickplayInfo, trickPlayInfo: TrickplayInfo,
@ -86,7 +86,7 @@ fun SeekPreviewImage(
val heightPx = with(LocalDensity.current) { height.toPx().toInt() } val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
val widthPx = with(LocalDensity.current) { width.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 numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
val imageIndex = index % numberOfTitlesPerImage val imageIndex = index % numberOfTitlesPerImage
@ -118,7 +118,7 @@ fun SeekPreviewImage(
) )
} }
Text( Text(
text = (seekProgress * duration / 1000).toLong().seconds.toString(), text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )