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,
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(),

View file

@ -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,

View file

@ -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),
)
},
)

View file

@ -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<Long>(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)
// }
// }
}

View file

@ -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,
)