add customizable seek bar

This commit is contained in:
Christian Schabesberger 2024-07-26 11:33:21 +02:00
parent 1375cfc877
commit 186fbf0c12
9 changed files with 1139 additions and 15 deletions

View File

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

View File

@ -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 =
@ -124,7 +128,7 @@ fun VideoPlayerControllerUI(
fullscreen = fullscreen fullscreen = fullscreen
) )
} }
AnimatedVisibility(uiVissible){ AnimatedVisibility(uiVissible) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), color = Color(0x75000000) modifier = Modifier.fillMaxSize(), color = Color(0x75000000)
) { ) {
@ -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 = {})
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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