diff --git a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModel.kt b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModel.kt index 73b8e07..f340b85 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModel.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/model/VideoPlayerViewModel.kt @@ -56,7 +56,8 @@ data class VideoPlayerUIState( var uiVisible: Boolean, val contentRatio: Float, val embeddedUiRatio: Float, - val contentFitMode: ContentScale + val contentFitMode: ContentScale, + val seekerPosition: Float ) : Parcelable { companion object { val DEFAULT = VideoPlayerUIState( @@ -66,7 +67,8 @@ data class VideoPlayerUIState( uiVisible = false, contentRatio = 16 / 9F, embeddedUiRatio = 16F / 9F, - contentFitMode = ContentScale.FIT_INSIDE + contentFitMode = ContentScale.FIT_INSIDE, + seekerPosition = 0F ) } } @@ -89,6 +91,8 @@ interface VideoPlayerViewModel { fun switchToEmbeddedView() fun showUi() fun hideUi() + fun seekPositionChanged(newValue: Float) + fun seekingFinished() interface Listener { 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() { callbackListener?.onFullscreenToggle(false) uiVisibilityJob?.cancel() @@ -303,6 +317,7 @@ class VideoPlayerViewModelImpl @Inject constructor( println("dummy impl") } + override fun play() { println("dummy impl") } @@ -323,6 +338,14 @@ class VideoPlayerViewModelImpl @Inject constructor( println("dummy impl") } + override fun seekPositionChanged(newValue: Float) { + println("dummy impl") + } + + override fun seekingFinished() { + println("dummy impl") + } + override fun pause() { println("dummy pause") } diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt index 939446a..2ff1c8b 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerControllerUI.kt @@ -91,6 +91,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.video_player_onSurface @@ -99,6 +100,7 @@ fun VideoPlayerControllerUI( isPlaying: Boolean, fullscreen: Boolean, uiVissible: Boolean, + seekPosition: Float, play: () -> Unit, pause: () -> Unit, prevStream: () -> Unit, @@ -106,7 +108,9 @@ fun VideoPlayerControllerUI( switchToFullscreen: () -> Unit, switchToEmbeddedView: () -> Unit, showUi: () -> Unit, - hideUi: () -> Unit + hideUi: () -> Unit, + seekPositionChanged: (Float) -> Unit, + seekingFinished: () -> Unit ) { val insets = @@ -124,7 +128,7 @@ fun VideoPlayerControllerUI( fullscreen = fullscreen ) } - AnimatedVisibility(uiVissible){ + AnimatedVisibility(uiVissible) { Surface( modifier = Modifier.fillMaxSize(), color = Color(0x75000000) ) { @@ -167,8 +171,11 @@ fun VideoPlayerControllerUI( .defaultMinSize(minHeight = 40.dp) .fillMaxWidth(), isFullscreen = fullscreen, + seekPosition, switchToFullscreen, - switchToEmbeddedView + switchToEmbeddedView, + seekPositionChanged, + seekingFinished ) } @@ -432,8 +439,11 @@ private fun CenterControllButton( private fun BottomUI( modifier: Modifier, isFullscreen: Boolean, + seekPosition: Float, switchToFullscreen: () -> Unit, - switchToEmbeddedView: () -> Unit + switchToEmbeddedView: () -> Unit, + seekPositionChanged: (Float) -> Unit, + seekingFinished: () -> Unit ) { Row( @@ -442,8 +452,17 @@ private fun BottomUI( modifier = modifier ) { Text("00:06:45") - Slider(value = 0.4F, onValueChange = {}, modifier = Modifier.weight(1F)) - Text("00:09:40") + Seeker( + 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) { Icon( imageVector = if (isFullscreen) Icons.Filled.FullscreenExit @@ -451,6 +470,7 @@ private fun BottomUI( contentDescription = stringResource(R.string.widget_description_toggle_fullscreen) ) } + } } @@ -482,6 +502,7 @@ fun VideoPlayerControllerUIPreviewEmbeded() { VideoPlayerControllerUI(isPlaying = false, fullscreen = false, uiVissible = true, + seekPosition = 0.3F, play = {}, pause = {}, prevStream = {}, @@ -489,7 +510,9 @@ fun VideoPlayerControllerUIPreviewEmbeded() { switchToFullscreen = {}, switchToEmbeddedView = {}, showUi = {}, - hideUi = {}) + hideUi = {}, + seekPositionChanged = {}, + seekingFinished = {}) } } } @@ -502,6 +525,7 @@ fun VideoPlayerControllerUIPreviewLandscape() { VideoPlayerControllerUI(isPlaying = true, fullscreen = true, uiVissible = true, + seekPosition = 0.3F, play = {}, pause = {}, prevStream = {}, @@ -509,7 +533,9 @@ fun VideoPlayerControllerUIPreviewLandscape() { switchToEmbeddedView = {}, switchToFullscreen = {}, showUi = {}, - hideUi = {}) + hideUi = {}, + seekPositionChanged = {}, + seekingFinished = {}) } } } @@ -523,6 +549,7 @@ fun VideoPlayerControllerUIPreviewPortrait() { isPlaying = false, fullscreen = true, uiVissible = true, + seekPosition = 0.3F, play = {}, pause = {}, prevStream = {}, @@ -530,7 +557,9 @@ fun VideoPlayerControllerUIPreviewPortrait() { switchToEmbeddedView = {}, switchToFullscreen = {}, showUi = {}, - hideUi = {}) + hideUi = {}, + seekPositionChanged = {}, + seekingFinished = {}) } } } \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt index f024190..f33ba3f 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/VideoPlayerUI.kt @@ -118,6 +118,7 @@ fun VideoPlayerUI( isPlaying = uiState.playing, fullscreen = uiState.fullscreen, uiVissible = uiState.uiVissible, + seekPosition = uiState.seekerPosition, play = viewModel::play, pause = viewModel::pause, prevStream = viewModel::prevStream, @@ -125,7 +126,9 @@ fun VideoPlayerUI( switchToFullscreen = viewModel::switchToFullscreen, switchToEmbeddedView = viewModel::switchToEmbeddedView, showUi = viewModel::showUi, - hideUi = viewModel::hideUi + hideUi = viewModel::hideUi, + seekPositionChanged = viewModel::seekPositionChanged, + seekingFinished = viewModel::seekingFinished ) } } diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/Seeker.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/Seeker.kt new file mode 100644 index 0000000..5a57ce2 --- /dev/null +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/Seeker.kt @@ -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 = 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 = 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, + 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, + 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, + 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 = { }, + ) +} \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerDefaults.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerDefaults.kt new file mode 100644 index 0000000..71892b7 --- /dev/null +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerDefaults.kt @@ -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 + + /** + * 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 + + /** + * 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 + + /** + * 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 +} + +/** + * 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 + + /** + * Represents the height used for the seeker's progress indicator. + */ + @Composable + fun progressHeight(): State + + /** + * Represents the gap used between two segments in seeker's track. + */ + @Composable + fun gap(): State + + /** + * Represents the radius used for seeker's thumb. + */ + @Composable + fun thumbRadius(): State +} + +@Immutable +internal class DefaultSeekerDimensions( + val trackHeight: Dp, + val progressHeight: Dp, + val gap: Dp, + val thumbRadius: Dp +) : SeekerDimensions { + @Composable + override fun trackHeight(): State { + return rememberUpdatedState(trackHeight) + } + + @Composable + override fun progressHeight(): State { + return rememberUpdatedState(progressHeight) + } + + @Composable + override fun gap(): State { + return rememberUpdatedState(gap) + } + + @Composable + override fun thumbRadius(): State { + 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 { + return rememberUpdatedState( + if (enabled) trackColor else disabledTrackColor + ) + } + + @Composable + override fun thumbColor(enabled: Boolean): State { + return rememberUpdatedState( + if (enabled) thumbColor else disabledThumbColor + ) + } + + @Composable + override fun progressColor(enabled: Boolean): State { + return rememberUpdatedState( + if (enabled) progressColor else disabledProgressColor + ) + } + + @Composable + override fun readAheadColor(enabled: Boolean): State { + 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 + } +} \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerState.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerState.kt new file mode 100644 index 0000000..4122919 --- /dev/null +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerState.kt @@ -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 + ) = (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 +) \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerUtils.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerUtils.kt new file mode 100644 index 0000000..761cf63 --- /dev/null +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/seeker/SeekerUtils.kt @@ -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 { + 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 { + 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, + range: ClosedFloatingPointRange, + widthPx: Float, +): List { + + 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 +} \ No newline at end of file diff --git a/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Theme.kt b/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Theme.kt index e6ad9c9..4547a68 100644 --- a/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Theme.kt +++ b/new-player/src/main/java/net/newpipe/newplayer/ui/theme/Theme.kt @@ -25,7 +25,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable -private val VideoPlayerColorScheme = darkColorScheme( +val VideoPlayerColorScheme = darkColorScheme( primary = video_player_primary, onPrimary = video_player_onPrimary, primaryContainer = video_player_primaryContainer, diff --git a/test-app/src/main/java/net/newpipe/newplayer/testapp/MainActivity.kt b/test-app/src/main/java/net/newpipe/newplayer/testapp/MainActivity.kt index df22b4f..33b5c61 100644 --- a/test-app/src/main/java/net/newpipe/newplayer/testapp/MainActivity.kt +++ b/test-app/src/main/java/net/newpipe/newplayer/testapp/MainActivity.kt @@ -59,10 +59,9 @@ class MainActivity : AppCompatActivity() { startStreamButton.setOnClickListener { newPlayer.playWhenReady = true - newPlayer.setStream(getString(R.string.portrait_video_example)) + newPlayer.setStream(getString(R.string.ccc_6502_video)) } - videoPlayerViewModel.newPlayer = newPlayer //videoPlayerViewModel.maxContentRatio = 4F/3F