add customizable seek bar
This commit is contained in:
parent
1375cfc877
commit
186fbf0c12
|
@ -56,7 +56,8 @@ data class VideoPlayerUIState(
|
||||||
var uiVisible: Boolean,
|
var uiVisible: Boolean,
|
||||||
val contentRatio: Float,
|
val contentRatio: Float,
|
||||||
val embeddedUiRatio: Float,
|
val embeddedUiRatio: Float,
|
||||||
val contentFitMode: ContentScale
|
val contentFitMode: ContentScale,
|
||||||
|
val seekerPosition: Float
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT = VideoPlayerUIState(
|
val DEFAULT = VideoPlayerUIState(
|
||||||
|
@ -66,7 +67,8 @@ data class VideoPlayerUIState(
|
||||||
uiVisible = false,
|
uiVisible = false,
|
||||||
contentRatio = 16 / 9F,
|
contentRatio = 16 / 9F,
|
||||||
embeddedUiRatio = 16F / 9F,
|
embeddedUiRatio = 16F / 9F,
|
||||||
contentFitMode = ContentScale.FIT_INSIDE
|
contentFitMode = ContentScale.FIT_INSIDE,
|
||||||
|
seekerPosition = 0F
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,6 +91,8 @@ interface VideoPlayerViewModel {
|
||||||
fun switchToEmbeddedView()
|
fun switchToEmbeddedView()
|
||||||
fun showUi()
|
fun showUi()
|
||||||
fun hideUi()
|
fun hideUi()
|
||||||
|
fun seekPositionChanged(newValue: Float)
|
||||||
|
fun seekingFinished()
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onFullscreenToggle(isFullscreen: Boolean)
|
fun onFullscreenToggle(isFullscreen: Boolean)
|
||||||
|
@ -260,6 +264,16 @@ class VideoPlayerViewModelImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun seekPositionChanged(newValue: Float) {
|
||||||
|
uiVisibilityJob?.cancel()
|
||||||
|
mutableUiState.update { it.copy(seekerPosition = newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekingFinished() {
|
||||||
|
resetHideUiDelayed()
|
||||||
|
Log.d(TAG, "TODO: Implement seeking Finished")
|
||||||
|
}
|
||||||
|
|
||||||
override fun switchToEmbeddedView() {
|
override fun switchToEmbeddedView() {
|
||||||
callbackListener?.onFullscreenToggle(false)
|
callbackListener?.onFullscreenToggle(false)
|
||||||
uiVisibilityJob?.cancel()
|
uiVisibilityJob?.cancel()
|
||||||
|
@ -303,6 +317,7 @@ class VideoPlayerViewModelImpl @Inject constructor(
|
||||||
println("dummy impl")
|
println("dummy impl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
println("dummy impl")
|
println("dummy impl")
|
||||||
}
|
}
|
||||||
|
@ -323,6 +338,14 @@ class VideoPlayerViewModelImpl @Inject constructor(
|
||||||
println("dummy impl")
|
println("dummy impl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun seekPositionChanged(newValue: Float) {
|
||||||
|
println("dummy impl")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekingFinished() {
|
||||||
|
println("dummy impl")
|
||||||
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
println("dummy pause")
|
println("dummy pause")
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,6 +91,7 @@ import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import net.newpipe.newplayer.R
|
import net.newpipe.newplayer.R
|
||||||
|
import net.newpipe.newplayer.ui.seeker.Seeker
|
||||||
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
|
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
|
||||||
import net.newpipe.newplayer.ui.theme.video_player_onSurface
|
import net.newpipe.newplayer.ui.theme.video_player_onSurface
|
||||||
|
|
||||||
|
@ -99,6 +100,7 @@ fun VideoPlayerControllerUI(
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
fullscreen: Boolean,
|
fullscreen: Boolean,
|
||||||
uiVissible: Boolean,
|
uiVissible: Boolean,
|
||||||
|
seekPosition: Float,
|
||||||
play: () -> Unit,
|
play: () -> Unit,
|
||||||
pause: () -> Unit,
|
pause: () -> Unit,
|
||||||
prevStream: () -> Unit,
|
prevStream: () -> Unit,
|
||||||
|
@ -106,7 +108,9 @@ fun VideoPlayerControllerUI(
|
||||||
switchToFullscreen: () -> Unit,
|
switchToFullscreen: () -> Unit,
|
||||||
switchToEmbeddedView: () -> Unit,
|
switchToEmbeddedView: () -> Unit,
|
||||||
showUi: () -> Unit,
|
showUi: () -> Unit,
|
||||||
hideUi: () -> Unit
|
hideUi: () -> Unit,
|
||||||
|
seekPositionChanged: (Float) -> Unit,
|
||||||
|
seekingFinished: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val insets =
|
val insets =
|
||||||
|
@ -167,8 +171,11 @@ fun VideoPlayerControllerUI(
|
||||||
.defaultMinSize(minHeight = 40.dp)
|
.defaultMinSize(minHeight = 40.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
isFullscreen = fullscreen,
|
isFullscreen = fullscreen,
|
||||||
|
seekPosition,
|
||||||
switchToFullscreen,
|
switchToFullscreen,
|
||||||
switchToEmbeddedView
|
switchToEmbeddedView,
|
||||||
|
seekPositionChanged,
|
||||||
|
seekingFinished
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -432,8 +439,11 @@ private fun CenterControllButton(
|
||||||
private fun BottomUI(
|
private fun BottomUI(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
isFullscreen: Boolean,
|
isFullscreen: Boolean,
|
||||||
|
seekPosition: Float,
|
||||||
switchToFullscreen: () -> Unit,
|
switchToFullscreen: () -> Unit,
|
||||||
switchToEmbeddedView: () -> Unit
|
switchToEmbeddedView: () -> Unit,
|
||||||
|
seekPositionChanged: (Float) -> Unit,
|
||||||
|
seekingFinished: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
@ -442,8 +452,17 @@ private fun BottomUI(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
Text("00:06:45")
|
Text("00:06:45")
|
||||||
Slider(value = 0.4F, onValueChange = {}, modifier = Modifier.weight(1F))
|
Seeker(
|
||||||
Text("00:09:40")
|
modifier.weight(1F),
|
||||||
|
value = seekPosition,
|
||||||
|
onValueChange = seekPositionChanged,
|
||||||
|
onValueChangeFinished = seekingFinished
|
||||||
|
)
|
||||||
|
|
||||||
|
//Slider(value = 0.4F, onValueChange = {}, modifier = Modifier.weight(1F))
|
||||||
|
|
||||||
|
//Text("00:09:40")
|
||||||
|
|
||||||
IconButton(onClick = if (isFullscreen) switchToEmbeddedView else switchToFullscreen) {
|
IconButton(onClick = if (isFullscreen) switchToEmbeddedView else switchToFullscreen) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isFullscreen) Icons.Filled.FullscreenExit
|
imageVector = if (isFullscreen) Icons.Filled.FullscreenExit
|
||||||
|
@ -451,6 +470,7 @@ private fun BottomUI(
|
||||||
contentDescription = stringResource(R.string.widget_description_toggle_fullscreen)
|
contentDescription = stringResource(R.string.widget_description_toggle_fullscreen)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -482,6 +502,7 @@ fun VideoPlayerControllerUIPreviewEmbeded() {
|
||||||
VideoPlayerControllerUI(isPlaying = false,
|
VideoPlayerControllerUI(isPlaying = false,
|
||||||
fullscreen = false,
|
fullscreen = false,
|
||||||
uiVissible = true,
|
uiVissible = true,
|
||||||
|
seekPosition = 0.3F,
|
||||||
play = {},
|
play = {},
|
||||||
pause = {},
|
pause = {},
|
||||||
prevStream = {},
|
prevStream = {},
|
||||||
|
@ -489,7 +510,9 @@ fun VideoPlayerControllerUIPreviewEmbeded() {
|
||||||
switchToFullscreen = {},
|
switchToFullscreen = {},
|
||||||
switchToEmbeddedView = {},
|
switchToEmbeddedView = {},
|
||||||
showUi = {},
|
showUi = {},
|
||||||
hideUi = {})
|
hideUi = {},
|
||||||
|
seekPositionChanged = {},
|
||||||
|
seekingFinished = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -502,6 +525,7 @@ fun VideoPlayerControllerUIPreviewLandscape() {
|
||||||
VideoPlayerControllerUI(isPlaying = true,
|
VideoPlayerControllerUI(isPlaying = true,
|
||||||
fullscreen = true,
|
fullscreen = true,
|
||||||
uiVissible = true,
|
uiVissible = true,
|
||||||
|
seekPosition = 0.3F,
|
||||||
play = {},
|
play = {},
|
||||||
pause = {},
|
pause = {},
|
||||||
prevStream = {},
|
prevStream = {},
|
||||||
|
@ -509,7 +533,9 @@ fun VideoPlayerControllerUIPreviewLandscape() {
|
||||||
switchToEmbeddedView = {},
|
switchToEmbeddedView = {},
|
||||||
switchToFullscreen = {},
|
switchToFullscreen = {},
|
||||||
showUi = {},
|
showUi = {},
|
||||||
hideUi = {})
|
hideUi = {},
|
||||||
|
seekPositionChanged = {},
|
||||||
|
seekingFinished = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -523,6 +549,7 @@ fun VideoPlayerControllerUIPreviewPortrait() {
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
fullscreen = true,
|
fullscreen = true,
|
||||||
uiVissible = true,
|
uiVissible = true,
|
||||||
|
seekPosition = 0.3F,
|
||||||
play = {},
|
play = {},
|
||||||
pause = {},
|
pause = {},
|
||||||
prevStream = {},
|
prevStream = {},
|
||||||
|
@ -530,7 +557,9 @@ fun VideoPlayerControllerUIPreviewPortrait() {
|
||||||
switchToEmbeddedView = {},
|
switchToEmbeddedView = {},
|
||||||
switchToFullscreen = {},
|
switchToFullscreen = {},
|
||||||
showUi = {},
|
showUi = {},
|
||||||
hideUi = {})
|
hideUi = {},
|
||||||
|
seekPositionChanged = {},
|
||||||
|
seekingFinished = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -118,6 +118,7 @@ fun VideoPlayerUI(
|
||||||
isPlaying = uiState.playing,
|
isPlaying = uiState.playing,
|
||||||
fullscreen = uiState.fullscreen,
|
fullscreen = uiState.fullscreen,
|
||||||
uiVissible = uiState.uiVissible,
|
uiVissible = uiState.uiVissible,
|
||||||
|
seekPosition = uiState.seekerPosition,
|
||||||
play = viewModel::play,
|
play = viewModel::play,
|
||||||
pause = viewModel::pause,
|
pause = viewModel::pause,
|
||||||
prevStream = viewModel::prevStream,
|
prevStream = viewModel::prevStream,
|
||||||
|
@ -125,7 +126,9 @@ fun VideoPlayerUI(
|
||||||
switchToFullscreen = viewModel::switchToFullscreen,
|
switchToFullscreen = viewModel::switchToFullscreen,
|
||||||
switchToEmbeddedView = viewModel::switchToEmbeddedView,
|
switchToEmbeddedView = viewModel::switchToEmbeddedView,
|
||||||
showUi = viewModel::showUi,
|
showUi = viewModel::showUi,
|
||||||
hideUi = viewModel::hideUi
|
hideUi = viewModel::hideUi,
|
||||||
|
seekPositionChanged = viewModel::seekPositionChanged,
|
||||||
|
seekingFinished = viewModel::seekingFinished
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,587 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Vivek Singh
|
||||||
|
*
|
||||||
|
* @Author Vivek Singh
|
||||||
|
* @Author Christian Schabesberger
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* Original code was taken from: https://github.com/2307vivek/Seeker/
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.newpipe.newplayer.ui.seeker
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.MutatePriority
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.indication
|
||||||
|
import androidx.compose.foundation.interaction.Interaction
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.requiredSizeIn
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.progressSemantics
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.inset
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotateRad
|
||||||
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.semantics.disabled
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.setProgress
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.atan2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seekbar/slider with support for read ahead indicator and segments. The segments can be
|
||||||
|
* separated with gaps in between or with their respective colors, or by both.
|
||||||
|
*
|
||||||
|
* Read ahead indicator shows the amount of content which is ready to use.
|
||||||
|
*
|
||||||
|
* @param modifier modifiers for the seeker layout
|
||||||
|
* @param state state for Seeker
|
||||||
|
* @param value current value of the seeker. If outside of [range] provided, value will be
|
||||||
|
* coerced to this range.
|
||||||
|
* @param thumbValue current value of the thumb. This allows the thumb to move independent of the
|
||||||
|
* progress position. If outside of [range] provided, value will be coerced to this range.
|
||||||
|
* @param progressStartPosition starting point of the indicator as a fraction of track width.
|
||||||
|
* The passed value will be clamped between 0 and 1.
|
||||||
|
* @param readAheadValue the read ahead value for seeker. If outside of [range] provided, value will be
|
||||||
|
* coerced to this range.
|
||||||
|
* @param onValueChange lambda in which value should be updated
|
||||||
|
* @param onValueChangeFinished lambda to be invoked when value change has ended. This callback
|
||||||
|
* shouldn't be used to update the slider value (use [onValueChange] for that), but rather to
|
||||||
|
* know when the user has completed selecting a new value by ending a drag or a click.
|
||||||
|
* @param segments a list of [Segment] for seeker. The track will be divided into different parts based
|
||||||
|
* on the provided start values.
|
||||||
|
* The first segment must start form the start value of the [range], and all the segments must lie in
|
||||||
|
* the specified [range], else an [IllegalArgumentException] will be thrown.
|
||||||
|
* will be thrown.
|
||||||
|
* @param enabled whether or not component is enabled and can be interacted with or not
|
||||||
|
* @param colors [SeekerColors] that will be used to determine the color of the Slider parts in
|
||||||
|
* different state. See [SeekerDefaults.seekerColors] to customize.
|
||||||
|
* @param dimensions [SeekerDimensions] that will be used to determine the dimensions of
|
||||||
|
* different Seeker parts in different state. See [SeekerDefaults.seekerDimensions] to customize.
|
||||||
|
* @param interactionSource the [MutableInteractionSource] representing the stream of
|
||||||
|
* [Interaction]s for this Seeker. You can create and pass in your own remembered
|
||||||
|
* [MutableInteractionSource] if you want to observe [Interaction]s and customize the
|
||||||
|
* appearance / behavior of this Seeker in different [Interaction]s.
|
||||||
|
* */
|
||||||
|
@Composable
|
||||||
|
fun Seeker(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: SeekerState = rememberSeekerState(),
|
||||||
|
value: Float,
|
||||||
|
thumbValue: Float = value,
|
||||||
|
range: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||||
|
@FloatRange(from = 0.0, to = 1.0)
|
||||||
|
progressStartPosition: Float = 0f,
|
||||||
|
readAheadValue: Float = lerp(range.start, range.endInclusive, progressStartPosition),
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
onValueChangeFinished: (() -> Unit)? = null,
|
||||||
|
segments: List<Segment> = emptyList(),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
colors: SeekerColors = SeekerDefaults.seekerColors(),
|
||||||
|
dimensions: SeekerDimensions = SeekerDefaults.seekerDimensions(),
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
if (segments.isNotEmpty()) {
|
||||||
|
require(segments.first().start == range.start) {
|
||||||
|
"the first segment should start from range start value"
|
||||||
|
}
|
||||||
|
segments.forEach {
|
||||||
|
require(it.start in range) {
|
||||||
|
"segment must start from withing the range."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onValueChangeState by rememberUpdatedState(onValueChange)
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.requiredSizeIn(
|
||||||
|
minHeight = SeekerDefaults.ThumbRippleRadius * 2,
|
||||||
|
minWidth = SeekerDefaults.ThumbRippleRadius * 2
|
||||||
|
)
|
||||||
|
.progressSemantics(value, range, onValueChange, onValueChangeFinished, enabled)
|
||||||
|
.focusable(enabled, interactionSource)
|
||||||
|
) {
|
||||||
|
val thumbRadius by dimensions.thumbRadius()
|
||||||
|
val trackStart: Float
|
||||||
|
val endPx = constraints.maxWidth.toFloat()
|
||||||
|
val widthPx: Float
|
||||||
|
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
trackStart = thumbRadius.toPx()
|
||||||
|
widthPx = endPx - (trackStart * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
val segmentStarts = remember(segments, range, widthPx) {
|
||||||
|
segmentToPxValues(segments, range, widthPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(thumbValue, segments) {
|
||||||
|
state.currentSegment(thumbValue, segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
val valuePx = remember(value, widthPx, range) {
|
||||||
|
valueToPx(value, widthPx, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
val thumbValuePx = remember(thumbValue, widthPx, range) {
|
||||||
|
when (thumbValue) {
|
||||||
|
value -> valuePx // reuse valuePx if thumbValue equal to value
|
||||||
|
else -> valueToPx(thumbValue, widthPx, range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val readAheadValuePx = remember(readAheadValue, widthPx, range) {
|
||||||
|
valueToPx(readAheadValue, widthPx, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dragPositionX by remember { mutableStateOf(0f) }
|
||||||
|
var pressOffset by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val draggableState = state.draggableState
|
||||||
|
|
||||||
|
LaunchedEffect(widthPx, range) {
|
||||||
|
state.onDrag = {
|
||||||
|
dragPositionX += it + pressOffset
|
||||||
|
|
||||||
|
pressOffset = 0f
|
||||||
|
onValueChangeState(pxToValue(dragPositionX, widthPx, range))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val press =
|
||||||
|
Modifier.pointerInput(
|
||||||
|
range,
|
||||||
|
widthPx,
|
||||||
|
endPx,
|
||||||
|
isRtl,
|
||||||
|
enabled,
|
||||||
|
thumbRadius,
|
||||||
|
interactionSource
|
||||||
|
) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = { position ->
|
||||||
|
dragPositionX = 0f
|
||||||
|
pressOffset =
|
||||||
|
if (!isRtl) position.x - trackStart else (endPx - position.x) - trackStart
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
scope.launch {
|
||||||
|
draggableState.drag(MutatePriority.UserInput) {
|
||||||
|
dragBy(0f)
|
||||||
|
}
|
||||||
|
onValueChangeFinished?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val drag = Modifier.draggable(
|
||||||
|
state = draggableState,
|
||||||
|
reverseDirection = isRtl,
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
onDragStopped = {
|
||||||
|
onValueChangeFinished?.invoke()
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
|
||||||
|
Seeker(
|
||||||
|
modifier = if (enabled) press.then(drag) else Modifier,
|
||||||
|
widthPx = widthPx,
|
||||||
|
valuePx = valuePx,
|
||||||
|
thumbValuePx = thumbValuePx,
|
||||||
|
progressStartPosition = progressStartPosition.coerceIn(0f, 1f),
|
||||||
|
readAheadValuePx = readAheadValuePx,
|
||||||
|
enabled = enabled,
|
||||||
|
segments = segmentStarts,
|
||||||
|
colors = colors,
|
||||||
|
dimensions = dimensions,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Seeker(
|
||||||
|
modifier: Modifier,
|
||||||
|
widthPx: Float,
|
||||||
|
valuePx: Float,
|
||||||
|
thumbValuePx: Float,
|
||||||
|
progressStartPosition: Float,
|
||||||
|
readAheadValuePx: Float,
|
||||||
|
enabled: Boolean,
|
||||||
|
segments: List<SegmentPxs>,
|
||||||
|
colors: SeekerColors,
|
||||||
|
dimensions: SeekerDimensions,
|
||||||
|
interactionSource: MutableInteractionSource
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.defaultSeekerDimensions(dimensions),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Track(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
enabled = enabled,
|
||||||
|
segments = segments,
|
||||||
|
colors = colors,
|
||||||
|
widthPx = widthPx,
|
||||||
|
valuePx = valuePx,
|
||||||
|
progressStartPosition = progressStartPosition,
|
||||||
|
readAheadValuePx = readAheadValuePx,
|
||||||
|
dimensions = dimensions
|
||||||
|
)
|
||||||
|
Thumb(
|
||||||
|
valuePx = { thumbValuePx },
|
||||||
|
dimensions = dimensions,
|
||||||
|
colors = colors,
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Track(
|
||||||
|
modifier: Modifier,
|
||||||
|
enabled: Boolean,
|
||||||
|
segments: List<SegmentPxs>,
|
||||||
|
colors: SeekerColors,
|
||||||
|
widthPx: Float,
|
||||||
|
valuePx: Float,
|
||||||
|
progressStartPosition: Float,
|
||||||
|
readAheadValuePx: Float,
|
||||||
|
dimensions: SeekerDimensions
|
||||||
|
) {
|
||||||
|
val trackColor by colors.trackColor(enabled)
|
||||||
|
val progressColor by colors.progressColor(enabled)
|
||||||
|
val readAheadColor by colors.readAheadColor(enabled)
|
||||||
|
val thumbRadius by dimensions.thumbRadius()
|
||||||
|
val trackHeight by dimensions.trackHeight()
|
||||||
|
val progressHeight by dimensions.progressHeight()
|
||||||
|
val segmentGap by dimensions.gap()
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier.graphicsLayer {
|
||||||
|
alpha = 0.99f
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val isRtl = layoutDirection == LayoutDirection.Rtl
|
||||||
|
val left = thumbRadius.toPx()
|
||||||
|
|
||||||
|
translate(left = left) {
|
||||||
|
if (segments.isEmpty()) {
|
||||||
|
// draw the track with a single line.
|
||||||
|
drawLine(
|
||||||
|
start = Offset(rtlAware(0f, widthPx, isRtl), center.y),
|
||||||
|
end = Offset(rtlAware(widthPx, widthPx, isRtl), center.y),
|
||||||
|
color = trackColor,
|
||||||
|
strokeWidth = trackHeight.toPx(),
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// draw segments in their respective color,
|
||||||
|
// excluding gaps (which will be cleared out later)
|
||||||
|
for (index in segments.indices) {
|
||||||
|
val segment = segments[index]
|
||||||
|
val segmentColor = when (segment.color) {
|
||||||
|
Color.Unspecified -> trackColor
|
||||||
|
else -> segment.color
|
||||||
|
}
|
||||||
|
drawSegment(
|
||||||
|
startPx = rtlAware(segment.startPx, widthPx, isRtl),
|
||||||
|
endPx = rtlAware(segment.endPx, widthPx, isRtl),
|
||||||
|
trackColor = segmentColor,
|
||||||
|
trackHeight = trackHeight.toPx(),
|
||||||
|
blendMode = BlendMode.SrcOver,
|
||||||
|
startCap = if (index == 0) StrokeCap.Round else null,
|
||||||
|
endCap = if (index == segments.lastIndex) StrokeCap.Round else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAhead indicator
|
||||||
|
drawLine(
|
||||||
|
start = Offset(rtlAware(widthPx * progressStartPosition, widthPx, isRtl), center.y),
|
||||||
|
end = Offset(rtlAware(readAheadValuePx, widthPx, isRtl), center.y),
|
||||||
|
color = readAheadColor,
|
||||||
|
strokeWidth = progressHeight.toPx(),
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
|
||||||
|
// progress indicator
|
||||||
|
drawLine(
|
||||||
|
start = Offset(rtlAware(widthPx * progressStartPosition, widthPx, isRtl), center.y),
|
||||||
|
end = Offset(rtlAware(valuePx, widthPx, isRtl), center.y),
|
||||||
|
color = progressColor,
|
||||||
|
strokeWidth = progressHeight.toPx(),
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
|
||||||
|
// clear segment gaps
|
||||||
|
for (index in segments.indices) {
|
||||||
|
if (index == segments.lastIndex) break // skip "gap" after last segment
|
||||||
|
val segment = segments[index]
|
||||||
|
drawGap(
|
||||||
|
startPx = rtlAware(segment.endPx - segmentGap.toPx(), widthPx, isRtl),
|
||||||
|
endPx = rtlAware(segment.endPx, widthPx, isRtl),
|
||||||
|
trackHeight = trackHeight.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawLine(
|
||||||
|
color: Color,
|
||||||
|
start: Offset,
|
||||||
|
end: Offset,
|
||||||
|
strokeWidth: Float = Stroke.HairlineWidth,
|
||||||
|
startCap: StrokeCap? = null,
|
||||||
|
endCap: StrokeCap? = null,
|
||||||
|
blendMode: BlendMode
|
||||||
|
) {
|
||||||
|
val endOffset = if (endCap != null) {
|
||||||
|
end.copy(x = end.x - strokeWidth)
|
||||||
|
} else {
|
||||||
|
end
|
||||||
|
}
|
||||||
|
inset(horizontal = strokeWidth / 2) {
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = start,
|
||||||
|
end = endOffset,
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
cap = StrokeCap.Butt,
|
||||||
|
)
|
||||||
|
|
||||||
|
startCap?.let {
|
||||||
|
drawCap(
|
||||||
|
color = color,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
cap = it,
|
||||||
|
blendMode = blendMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
endCap?.let {
|
||||||
|
drawCap(
|
||||||
|
color = color,
|
||||||
|
start = endOffset,
|
||||||
|
end = start,
|
||||||
|
strokeWidth = strokeWidth,
|
||||||
|
cap = it,
|
||||||
|
blendMode = blendMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawCap(
|
||||||
|
color: Color,
|
||||||
|
start: Offset,
|
||||||
|
end: Offset,
|
||||||
|
strokeWidth: Float = Stroke.HairlineWidth,
|
||||||
|
cap: StrokeCap,
|
||||||
|
blendMode: BlendMode
|
||||||
|
) {
|
||||||
|
when (cap) {
|
||||||
|
StrokeCap.Butt -> Unit
|
||||||
|
StrokeCap.Round -> {
|
||||||
|
drawArc(
|
||||||
|
color = color,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 180f,
|
||||||
|
useCenter = true,
|
||||||
|
topLeft = start - Offset(strokeWidth / 2, strokeWidth / 2),
|
||||||
|
size = Size(strokeWidth, strokeWidth),
|
||||||
|
blendMode = blendMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StrokeCap.Square -> {
|
||||||
|
val offset = Offset(strokeWidth / 2, strokeWidth / 2)
|
||||||
|
val size = Size(strokeWidth, strokeWidth)
|
||||||
|
|
||||||
|
rotateRad(
|
||||||
|
radians = (end - start).run { atan2(x, y) },
|
||||||
|
pivot = start
|
||||||
|
) {
|
||||||
|
drawRect(color, topLeft = start - offset, size = size, blendMode = blendMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSegment(
|
||||||
|
startPx: Float,
|
||||||
|
endPx: Float,
|
||||||
|
trackColor: Color,
|
||||||
|
trackHeight: Float,
|
||||||
|
blendMode: BlendMode,
|
||||||
|
startCap: StrokeCap? = null,
|
||||||
|
endCap: StrokeCap? = null
|
||||||
|
) {
|
||||||
|
drawLine(
|
||||||
|
start = Offset(startPx, center.y),
|
||||||
|
end = Offset(endPx, center.y),
|
||||||
|
color = trackColor,
|
||||||
|
strokeWidth = trackHeight,
|
||||||
|
blendMode = blendMode,
|
||||||
|
endCap = endCap,
|
||||||
|
startCap = startCap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawGap(
|
||||||
|
startPx: Float,
|
||||||
|
endPx: Float,
|
||||||
|
trackHeight: Float,
|
||||||
|
) {
|
||||||
|
drawLine(
|
||||||
|
start = Offset(startPx, center.y),
|
||||||
|
end = Offset(endPx, center.y),
|
||||||
|
color = Color.Black, // any color will do
|
||||||
|
strokeWidth = trackHeight + 2, // add 2 to prevent hairline borders from rounding
|
||||||
|
blendMode = BlendMode.Clear
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Thumb(
|
||||||
|
valuePx: () -> Float,
|
||||||
|
dimensions: SeekerDimensions,
|
||||||
|
colors: SeekerColors,
|
||||||
|
enabled: Boolean,
|
||||||
|
interactionSource: MutableInteractionSource
|
||||||
|
) {
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(x = valuePx().toInt(), 0)
|
||||||
|
}
|
||||||
|
.indication(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = rememberRipple(
|
||||||
|
bounded = false,
|
||||||
|
radius = SeekerDefaults.ThumbRippleRadius
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.hoverable(interactionSource)
|
||||||
|
.size(dimensions.thumbRadius().value * 2)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(colors.thumbColor(enabled = enabled).value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.defaultSeekerDimensions(dimensions: SeekerDimensions) = composed {
|
||||||
|
this.then(
|
||||||
|
Modifier
|
||||||
|
.heightIn(
|
||||||
|
max = (dimensions.thumbRadius().value * 2).coerceAtLeast(SeekerDefaults.MinSliderHeight)
|
||||||
|
)
|
||||||
|
.widthIn(
|
||||||
|
min = SeekerDefaults.MinSliderWidth
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.progressSemantics(
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
onValueChangeFinished: (() -> Unit)? = null,
|
||||||
|
enabled: Boolean
|
||||||
|
): Modifier {
|
||||||
|
val coerced = value.coerceIn(range.start, range.endInclusive)
|
||||||
|
return semantics {
|
||||||
|
if (!enabled) disabled()
|
||||||
|
setProgress { targetValue ->
|
||||||
|
val newValue = targetValue.coerceIn(range.start, range.endInclusive)
|
||||||
|
|
||||||
|
if (newValue == coerced) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
onValueChange(newValue)
|
||||||
|
onValueChangeFinished?.invoke()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.progressSemantics(value, range, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun SeekerPreview() {
|
||||||
|
val segments = listOf(
|
||||||
|
Segment(name = "Intro", start = 0f),
|
||||||
|
Segment(name = "Talk 1", start = 0.5f),
|
||||||
|
Segment(name = "Talk 2", start = 0.8f),
|
||||||
|
)
|
||||||
|
Seeker(
|
||||||
|
value = 0.7f,
|
||||||
|
range = 0f..1f,
|
||||||
|
segments = segments,
|
||||||
|
onValueChange = { },
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,309 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Vivek Singh
|
||||||
|
*
|
||||||
|
* @Author Vivek Singh
|
||||||
|
* @Author Christian Schabesberger
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* Original code was taken from: https://github.com/2307vivek/Seeker/
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
package net.newpipe.newplayer.ui.seeker
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import net.newpipe.newplayer.ui.theme.VideoPlayerColorScheme
|
||||||
|
|
||||||
|
object SeekerDefaults {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [SeekerColors] that represents the different colors used in parts of the
|
||||||
|
* [Seeker] in different states.
|
||||||
|
*
|
||||||
|
* @param progressColor color of the progress indicator.
|
||||||
|
* @param trackColor color of the track.
|
||||||
|
* @param disabledProgressColor color of the progress indicator when the Slider is
|
||||||
|
* disabled.
|
||||||
|
* @param disabledTrackColor color of the track when theSlider is disabled.
|
||||||
|
* @param thumbColor thumb color when enabled
|
||||||
|
* @param disabledThumbColor thumb color when disabled.
|
||||||
|
* @param readAheadColor color of the read ahead indicator.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun seekerColors(
|
||||||
|
progressColor: Color = VideoPlayerColorScheme.primary,
|
||||||
|
trackColor: Color = TrackColor,
|
||||||
|
disabledProgressColor: Color = VideoPlayerColorScheme.onSurface.copy(alpha = DisabledProgressAlpha),
|
||||||
|
disabledTrackColor: Color = disabledProgressColor
|
||||||
|
.copy(alpha = DisabledTrackAlpha)
|
||||||
|
.compositeOver(VideoPlayerColorScheme.onSurface),
|
||||||
|
thumbColor: Color = VideoPlayerColorScheme.primary,
|
||||||
|
disabledThumbColor: Color = VideoPlayerColorScheme.onSurface
|
||||||
|
.copy(alpha = 0.0F)
|
||||||
|
.compositeOver(VideoPlayerColorScheme.surface),
|
||||||
|
readAheadColor: Color = ReadAheadColor
|
||||||
|
): SeekerColors = DefaultSeekerColor(
|
||||||
|
progressColor = progressColor,
|
||||||
|
trackColor = trackColor,
|
||||||
|
disabledProgressColor = disabledProgressColor,
|
||||||
|
disabledTrackColor = disabledTrackColor,
|
||||||
|
thumbColor = thumbColor,
|
||||||
|
disabledThumbColor = disabledThumbColor,
|
||||||
|
readAheadColor = readAheadColor
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [SeekerDimensions] which represents dimension of different parts of [Seeker] in
|
||||||
|
* different states.
|
||||||
|
*
|
||||||
|
* @param trackHeight height of the track.
|
||||||
|
* @param progressHeight height of the progress indicator.
|
||||||
|
* @param thumbRadius radius of the thumb slider.
|
||||||
|
* @param gap gap between two segments in the track.
|
||||||
|
* */
|
||||||
|
@Composable
|
||||||
|
fun seekerDimensions(
|
||||||
|
trackHeight: Dp = TrackHeight,
|
||||||
|
progressHeight: Dp = trackHeight,
|
||||||
|
thumbRadius: Dp = ThumbRadius,
|
||||||
|
gap: Dp = Gap
|
||||||
|
): SeekerDimensions = DefaultSeekerDimensions(
|
||||||
|
trackHeight = trackHeight,
|
||||||
|
progressHeight = progressHeight,
|
||||||
|
thumbRadius = thumbRadius,
|
||||||
|
gap = gap
|
||||||
|
)
|
||||||
|
|
||||||
|
private val TrackColor = Color(0xFFD9D9D9)
|
||||||
|
private val ReadAheadColor = Color(0xFFBDBDBD)
|
||||||
|
|
||||||
|
private const val TrackAlpha = 0.24f
|
||||||
|
private const val ReadAheadAlpha = 0.44f
|
||||||
|
private const val DisabledTrackAlpha = 0.22f
|
||||||
|
private const val DisabledProgressAlpha = 0.32f
|
||||||
|
|
||||||
|
internal val ThumbRadius = 10.dp
|
||||||
|
private val TrackHeight = 4.dp
|
||||||
|
private val Gap = 2.dp
|
||||||
|
|
||||||
|
internal val MinSliderHeight = 48.dp
|
||||||
|
internal val MinSliderWidth = ThumbRadius * 2
|
||||||
|
|
||||||
|
internal val ThumbDefaultElevation = 1.dp
|
||||||
|
internal val ThumbPressedElevation = 6.dp
|
||||||
|
|
||||||
|
internal val ThumbRippleRadius = 24.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the colors used by different parts of [Seeker] in different states.
|
||||||
|
*
|
||||||
|
* See [SeekerDefaults.seekerColors] for default implementation.
|
||||||
|
* */
|
||||||
|
@Stable
|
||||||
|
interface SeekerColors {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the color used for the seeker's track, depending on [enabled].
|
||||||
|
*
|
||||||
|
* @param enabled whether the [Seeker] is enabled or not
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun trackColor(enabled: Boolean): State<Color>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the color used for the seeker's thumb, depending on [enabled].
|
||||||
|
*
|
||||||
|
* @param enabled whether the [Seeker] is enabled or not
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun thumbColor(enabled: Boolean): State<Color>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the color used for the seeker's progress indicator, depending on [enabled].
|
||||||
|
*
|
||||||
|
* @param enabled whether the [Seeker] is enabled or not
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun progressColor(enabled: Boolean): State<Color>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the color used for the seeker's read ahead indicator, depending on [enabled].
|
||||||
|
*
|
||||||
|
* @param enabled whether the [Seeker] is enabled or not
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun readAheadColor(enabled: Boolean): State<Color>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the dimensions used by different parts of [Seeker] in different states.
|
||||||
|
*
|
||||||
|
* See [SeekerDefaults.seekerDimensions] for default implementation.
|
||||||
|
* */
|
||||||
|
@Stable
|
||||||
|
interface SeekerDimensions {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the height used for the seeker's track.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun trackHeight(): State<Dp>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the height used for the seeker's progress indicator.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun progressHeight(): State<Dp>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the gap used between two segments in seeker's track.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun gap(): State<Dp>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the radius used for seeker's thumb.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun thumbRadius(): State<Dp>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
internal class DefaultSeekerDimensions(
|
||||||
|
val trackHeight: Dp,
|
||||||
|
val progressHeight: Dp,
|
||||||
|
val gap: Dp,
|
||||||
|
val thumbRadius: Dp
|
||||||
|
) : SeekerDimensions {
|
||||||
|
@Composable
|
||||||
|
override fun trackHeight(): State<Dp> {
|
||||||
|
return rememberUpdatedState(trackHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun progressHeight(): State<Dp> {
|
||||||
|
return rememberUpdatedState(progressHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun gap(): State<Dp> {
|
||||||
|
return rememberUpdatedState(gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun thumbRadius(): State<Dp> {
|
||||||
|
return rememberUpdatedState(thumbRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null || this::class != other::class) return false
|
||||||
|
|
||||||
|
other as DefaultSeekerDimensions
|
||||||
|
|
||||||
|
if (trackHeight != other.trackHeight) return false
|
||||||
|
if (progressHeight != other.progressHeight) return false
|
||||||
|
if (gap != other.gap) return false
|
||||||
|
if (thumbRadius != other.thumbRadius) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = trackHeight.hashCode()
|
||||||
|
|
||||||
|
result = 31 * result + progressHeight.hashCode()
|
||||||
|
result = 31 * result + gap.hashCode()
|
||||||
|
result = 31 * result + thumbRadius.hashCode()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
internal class DefaultSeekerColor(
|
||||||
|
val progressColor: Color,
|
||||||
|
val trackColor: Color,
|
||||||
|
val disabledTrackColor: Color,
|
||||||
|
val disabledProgressColor: Color,
|
||||||
|
val thumbColor: Color,
|
||||||
|
val disabledThumbColor: Color,
|
||||||
|
val readAheadColor: Color
|
||||||
|
) : SeekerColors {
|
||||||
|
@Composable
|
||||||
|
override fun trackColor(enabled: Boolean): State<Color> {
|
||||||
|
return rememberUpdatedState(
|
||||||
|
if (enabled) trackColor else disabledTrackColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun thumbColor(enabled: Boolean): State<Color> {
|
||||||
|
return rememberUpdatedState(
|
||||||
|
if (enabled) thumbColor else disabledThumbColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun progressColor(enabled: Boolean): State<Color> {
|
||||||
|
return rememberUpdatedState(
|
||||||
|
if (enabled) progressColor else disabledProgressColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun readAheadColor(enabled: Boolean): State<Color> {
|
||||||
|
return rememberUpdatedState(
|
||||||
|
readAheadColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null || this::class != other::class) return false
|
||||||
|
|
||||||
|
other as DefaultSeekerColor
|
||||||
|
|
||||||
|
if (progressColor != other.progressColor) return false
|
||||||
|
if (trackColor != other.trackColor) return false
|
||||||
|
if (disabledTrackColor != other.disabledTrackColor) return false
|
||||||
|
if (disabledProgressColor != other.disabledProgressColor) return false
|
||||||
|
if (thumbColor != other.thumbColor) return false
|
||||||
|
if (disabledThumbColor != other.disabledThumbColor) return false
|
||||||
|
if (readAheadColor != other.readAheadColor) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = progressColor.hashCode()
|
||||||
|
|
||||||
|
result = 31 * result + trackColor.hashCode()
|
||||||
|
result = 31 * result + disabledTrackColor.hashCode()
|
||||||
|
result = 31 * result + disabledProgressColor.hashCode()
|
||||||
|
result = 31 * result + thumbColor.hashCode()
|
||||||
|
result = 31 * result + disabledThumbColor.hashCode()
|
||||||
|
result = 31 * result + readAheadColor.hashCode()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Vivek Singh
|
||||||
|
*
|
||||||
|
* @Author Vivek Singh
|
||||||
|
* @Author Christian Schabesberger
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* Original code was taken from: https://github.com/2307vivek/Seeker/
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
package net.newpipe.newplayer.ui.seeker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.DraggableState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A state object which can be hoisted to observe the current segment of Seeker. In most cases this
|
||||||
|
* will be created by [rememberSeekerState]
|
||||||
|
* */
|
||||||
|
@Stable
|
||||||
|
class SeekerState() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current segment corresponding to the current seeker value.
|
||||||
|
* */
|
||||||
|
var currentSegment: Segment by mutableStateOf(Segment.Unspecified)
|
||||||
|
|
||||||
|
internal var onDrag: ((Float) -> Unit)? = null
|
||||||
|
|
||||||
|
internal val draggableState = DraggableState {
|
||||||
|
onDrag?.invoke(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun currentSegment(
|
||||||
|
value: Float,
|
||||||
|
segments: List<Segment>
|
||||||
|
) = (segments.findLast { value >= it.start } ?: Segment.Unspecified).also { this.currentSegment = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a SeekerState which will be remembered across compositions.
|
||||||
|
* */
|
||||||
|
@Composable
|
||||||
|
fun rememberSeekerState(): SeekerState = remember {
|
||||||
|
SeekerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class to hold information about a segment.
|
||||||
|
* @param name name of the segment
|
||||||
|
* @param start the value at which this segment should start in the track. This should must be in the
|
||||||
|
* range of the Seeker range values.
|
||||||
|
* @param color the color of the segment
|
||||||
|
* */
|
||||||
|
@Immutable
|
||||||
|
data class Segment(
|
||||||
|
val name: String,
|
||||||
|
val start: Float,
|
||||||
|
val color: Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Unspecified = Segment(name = "", start = 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
internal data class SegmentPxs(
|
||||||
|
val name: String,
|
||||||
|
val startPx: Float,
|
||||||
|
val endPx: Float,
|
||||||
|
val color: Color
|
||||||
|
)
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Vivek Singh
|
||||||
|
*
|
||||||
|
* @Author Vivek Singh
|
||||||
|
* @Author Christian Schabesberger
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* Original code was taken from: https://github.com/2307vivek/Seeker/
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
package net.newpipe.newplayer.ui.seeker
|
||||||
|
|
||||||
|
// returns the corresponding position in pixels of progress in the the slider.
|
||||||
|
internal fun valueToPx(
|
||||||
|
value: Float,
|
||||||
|
widthPx: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>
|
||||||
|
): Float {
|
||||||
|
val rangeSIze = range.endInclusive - range.start
|
||||||
|
val p = value.coerceIn(range.start, range.endInclusive)
|
||||||
|
val progressPercent = (p - range.start) * 100 / rangeSIze
|
||||||
|
return (progressPercent * widthPx / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns the corresponding progress value for a position in slider
|
||||||
|
internal fun pxToValue(
|
||||||
|
position: Float,
|
||||||
|
widthPx: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>
|
||||||
|
): Float {
|
||||||
|
val rangeSize = range.endInclusive - range.start
|
||||||
|
val percent = position * 100 / widthPx
|
||||||
|
return ((percent * (rangeSize) / 100) + range.start).coerceIn(
|
||||||
|
range.start,
|
||||||
|
range.endInclusive
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// converts the start value of a segment to the corresponding start and end pixel values
|
||||||
|
// at which the segment will start and end on the track.
|
||||||
|
internal fun segmentToPxValues(
|
||||||
|
segments: List<Segment>,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
widthPx: Float,
|
||||||
|
): List<SegmentPxs> {
|
||||||
|
|
||||||
|
val rangeSize = range.endInclusive - range.start
|
||||||
|
val sortedSegments = segments.distinct().sortedBy { it.start }
|
||||||
|
val segmentStartPxs = sortedSegments.map { segment ->
|
||||||
|
|
||||||
|
// percent of the start of this segment in the range size
|
||||||
|
val percent = (segment.start - range.start) * 100 / rangeSize
|
||||||
|
val startPx = percent * widthPx / 100
|
||||||
|
startPx
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedSegments.mapIndexed { index, segment ->
|
||||||
|
val endPx = if (index != sortedSegments.lastIndex) segmentStartPxs[index + 1] else widthPx
|
||||||
|
SegmentPxs(
|
||||||
|
name = segment.name,
|
||||||
|
color = segment.color,
|
||||||
|
startPx = segmentStartPxs[index],
|
||||||
|
endPx = endPx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun rtlAware(value: Float, widthPx: Float, isRtl: Boolean) =
|
||||||
|
if (isRtl) widthPx - value else value
|
||||||
|
|
||||||
|
internal fun lerp(start: Float, end: Float, fraction: Float): Float {
|
||||||
|
return (1 - fraction) * start + fraction * end
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ import androidx.compose.material3.darkColorScheme
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
private val VideoPlayerColorScheme = darkColorScheme(
|
val VideoPlayerColorScheme = darkColorScheme(
|
||||||
primary = video_player_primary,
|
primary = video_player_primary,
|
||||||
onPrimary = video_player_onPrimary,
|
onPrimary = video_player_onPrimary,
|
||||||
primaryContainer = video_player_primaryContainer,
|
primaryContainer = video_player_primaryContainer,
|
||||||
|
|
|
@ -59,10 +59,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
startStreamButton.setOnClickListener {
|
startStreamButton.setOnClickListener {
|
||||||
newPlayer.playWhenReady = true
|
newPlayer.playWhenReady = true
|
||||||
newPlayer.setStream(getString(R.string.portrait_video_example))
|
newPlayer.setStream(getString(R.string.ccc_6502_video))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
videoPlayerViewModel.newPlayer = newPlayer
|
videoPlayerViewModel.newPlayer = newPlayer
|
||||||
|
|
||||||
//videoPlayerViewModel.maxContentRatio = 4F/3F
|
//videoPlayerViewModel.maxContentRatio = 4F/3F
|
||||||
|
|
Loading…
Reference in New Issue