make chapters visible in the seekbar

This commit is contained in:
Christian Schabesberger 2024-09-06 17:33:13 +02:00
parent 8a3cc12bd7
commit 430bed20b6
11 changed files with 157 additions and 107 deletions

View File

@ -81,7 +81,7 @@ data class VideoPlayerUIState(
seekerPosition = 0.3f,
bufferedPercentage = 0.5f,
isLoading = false,
durationInMs = 420,
durationInMs = 12000,
playbackPositionInPlaylistS = 5039,
playbackPositionInMs = 69,
fastSeekSeconds = 10,
@ -92,6 +92,11 @@ data class VideoPlayerUIState(
currentlyPlaying = PlaylistItem.DUMMY,
currentPlaylistItemIndex = 1,
chapters = arrayListOf(
Chapter(
chapterStartInMs = 0,
chapterTitle = "Intro",
thumbnail = null
),
Chapter(
chapterStartInMs = 5000,
chapterTitle = "First Chapter",
@ -102,11 +107,6 @@ data class VideoPlayerUIState(
chapterTitle = "Second Chapter",
thumbnail = null
),
Chapter(
chapterStartInMs = 20000,
chapterTitle = "Third Chapter",
thumbnail = null
)
),
playList = arrayListOf(
PlaylistItem(

View File

@ -42,7 +42,7 @@ interface VideoPlayerViewModel {
fun pause()
fun prevStream()
fun nextStream()
fun switchToFullscreen(embeddedUiConfig: EmbeddedUiConfig)
fun switchToFullscreen()
fun switchToEmbeddedView()
fun onBackPressed()
fun showUi()
@ -54,7 +54,7 @@ interface VideoPlayerViewModel {
fun finishFastSeek()
fun brightnessChange(changeRate: Float, systemBrightness: Float)
fun volumeChange(changeRate: Float)
fun openStreamSelection(selectChapter: Boolean, embeddedUiConfig: EmbeddedUiConfig)
fun openStreamSelection(selectChapter: Boolean)
fun closeStreamSelection()
fun chapterSelected(chapterId: Int)
fun streamSelected(streamId: Int)

View File

@ -432,11 +432,8 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
}
override fun openStreamSelection(selectChapter: Boolean, embeddedUiConfig: EmbeddedUiConfig) {
override fun openStreamSelection(selectChapter: Boolean) {
uiVisibilityJob?.cancel()
if (!uiState.value.uiMode.fullscreen) {
this.embeddedUiConfig = embeddedUiConfig
}
updateUiMode(
if (selectChapter) uiState.value.uiMode.getChapterSelectUiState()
else uiState.value.uiMode.getStreamSelectUiState()
@ -469,11 +466,10 @@ class VideoPlayerViewModelImpl @Inject constructor(
}
}
override fun switchToFullscreen(embeddedUiConfig: EmbeddedUiConfig) {
override fun switchToFullscreen() {
uiVisibilityJob?.cancel()
finishFastSeek()
this.embeddedUiConfig = embeddedUiConfig
updateUiMode(UIModeState.FULLSCREEN_VIDEO)
}

View File

@ -36,7 +36,7 @@ open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
println("dummy impl")
}
override fun switchToFullscreen(embeddedUiConfig: EmbeddedUiConfig) {
override fun switchToFullscreen() {
println("dummy impl")
}
@ -61,7 +61,7 @@ open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
}
override fun fastSeek(steps: Int) {
println("dummy impl")
println("dummy impl, steps: $steps")
}
override fun finishFastSeek() {
@ -76,7 +76,7 @@ open class VideoPlayerViewModelDummy : VideoPlayerViewModel {
println("dummy impl")
}
override fun openStreamSelection(selectChapter: Boolean, embeddedUiConfig: EmbeddedUiConfig) {
override fun openStreamSelection(selectChapter: Boolean) {
println("dummy impl")
}

View File

@ -36,11 +36,8 @@ 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredSizeIn
@ -49,8 +46,6 @@ 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.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -75,7 +70,6 @@ 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.layout.VerticalAlignmentLine
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.disabled
@ -135,18 +129,25 @@ fun Seeker(
onValueChange: (Float) -> Unit,
onValueChangeFinished: (() -> Unit)? = null,
segments: List<Segment> = emptyList(),
chapterSegments: List<ChapterSegment> = 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 && it.end in range) {
"segment must lie withing the range: segment: ${it.name} start: ${it.start}, end: ${it.end}, range: ${range}"
}
}
}
if (chapterSegments.isNotEmpty()) {
chapterSegments.forEach {
require(it.start in range) {
"segment must start from withing the range."
"chapter segment must lie withing the range: segment: ${it.name} start: ${it.start} range: ${range}"
}
}
}
@ -178,6 +179,10 @@ fun Seeker(
segmentToPxValues(segments, range, widthPx)
}
val chapterSegmentsPx = remember(chapterSegments, range, widthPx) {
chapterSegmentToPxValues(chapterSegments, range, widthPx)
}
LaunchedEffect(thumbValue, segments) {
state.currentSegment(thumbValue, segments)
}
@ -259,6 +264,7 @@ fun Seeker(
readAheadValuePx = readAheadValuePx,
enabled = enabled,
segments = segmentStarts,
chapterSegments = chapterSegmentsPx,
colors = colors,
dimensions = dimensions,
interactionSource = interactionSource
@ -276,6 +282,7 @@ private fun Seeker(
readAheadValuePx: Float,
enabled: Boolean,
segments: List<SegmentPxs>,
chapterSegments: List<SegmentPxs>,
colors: SeekerColors,
dimensions: SeekerDimensions,
interactionSource: MutableInteractionSource
@ -288,6 +295,7 @@ private fun Seeker(
modifier = Modifier.fillMaxSize(),
enabled = enabled,
segments = segments,
chapterSegments = chapterSegments,
colors = colors,
widthPx = widthPx,
valuePx = valuePx,
@ -310,6 +318,7 @@ private fun Track(
modifier: Modifier,
enabled: Boolean,
segments: List<SegmentPxs>,
chapterSegments: List<SegmentPxs>,
colors: SeekerColors,
widthPx: Float,
valuePx: Float,
@ -334,7 +343,7 @@ private fun Track(
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),
@ -343,26 +352,7 @@ private fun Track(
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(
@ -382,14 +372,28 @@ private fun Track(
cap = StrokeCap.Round
)
// clear segment gaps
// draw segments in their respective color,
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),
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,
)
}
// clear segment gaps
for (index in chapterSegments.indices) {
val segment = chapterSegments[index]
drawDot(
x = rtlAware(segment.startPx, widthPx, isRtl),
trackHeight = trackHeight.toPx()
)
}
}
@ -485,8 +489,6 @@ private fun DrawScope.drawSegment(
trackColor: Color,
trackHeight: Float,
blendMode: BlendMode,
startCap: StrokeCap? = null,
endCap: StrokeCap? = null
) {
drawLine(
start = Offset(startPx, center.y),
@ -494,8 +496,7 @@ private fun DrawScope.drawSegment(
color = trackColor,
strokeWidth = trackHeight,
blendMode = blendMode,
endCap = endCap,
startCap = startCap
cap = StrokeCap.Round
)
}
@ -513,6 +514,18 @@ private fun DrawScope.drawGap(
)
}
private fun DrawScope.drawDot(
x: Float,
trackHeight: Float
) {
drawCircle(
radius = (trackHeight / 2f) * 0.8f,
center = Offset(x = x, y = center.y),
color = Color.Gray.copy(alpha = 0.9f),
blendMode = BlendMode.SrcOver
)
}
@Composable
private fun Thumb(
valuePx: () -> Float,
@ -580,14 +593,21 @@ private fun Modifier.progressSemantics(
@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),
Segment(name = "Intro", start = 0.1f, end = 0.3f, color = Color.Green),
Segment(name = "Talk 1", start = 0.5f, end = 0.6f, color = Color.Cyan),
Segment(name = "Talk 2", start = 0.8f, end = 0.85f, color = Color.Blue),
)
val chapterSegments = listOf(
ChapterSegment(name = "Intro", start = 0.0f, color = Color.Green),
ChapterSegment(name = "Talk 1", start = 0.55f, color = Color.Cyan),
ChapterSegment(name = "Talk 2", start = 0.9f, color = Color.Blue),
)
Seeker(
value = 0.7f,
range = 0f..1f,
segments = segments,
chapterSegments = chapterSegments,
onValueChange = { },
)
}

View File

@ -74,10 +74,22 @@ fun rememberSeekerState(): SeekerState = remember {
data class Segment(
val name: String,
val start: Float,
val end: Float,
val color: Color = Color.Unspecified
) {
companion object {
val Unspecified = Segment(name = "", start = 0f)
val Unspecified = Segment(name = "", start = 0f, end = 0f)
}
}
@Immutable
data class ChapterSegment(
val name: String,
val start: Float,
val color: Color = Color.Unspecified
) {
companion object {
val Unspecified = ChapterSegment(name = "", start = 0f)
}
}

View File

@ -57,6 +57,37 @@ internal fun segmentToPxValues(
val rangeSize = range.endInclusive - range.start
val sortedSegments = segments.distinct().sortedBy { it.start }
val segmentRangesPxs = sortedSegments.map { segment ->
// percent of the start of this segment in the range size
val percentStart = (segment.start - range.start) * 100 / rangeSize
val percentEnd = (segment.end - range.start) * 100 / rangeSize
val startPx = percentStart * widthPx / 100
val endPx = percentEnd * widthPx / 100
Pair(startPx, endPx)
}
return sortedSegments.mapIndexed { index, segment ->
SegmentPxs(
name = segment.name,
color = segment.color,
startPx = segmentRangesPxs[index].first,
endPx = segmentRangesPxs[index].second
)
}
}
internal fun chapterSegmentToPxValues(
segments: List<ChapterSegment>,
range: ClosedFloatingPointRange<Float>,
widthPx: Float,
): List<SegmentPxs> {
val rangeSize = range.endInclusive - range.start
val sortedSegments = ArrayList(segments.distinct().sortedBy { it.start })
if(sortedSegments.isNotEmpty()) {
sortedSegments.removeAt(0)
}
val segmentStartPxs = sortedSegments.map { segment ->
// percent of the start of this segment in the range size

View File

@ -23,6 +23,7 @@ package net.newpipe.newplayer.ui.videoplayer
import android.app.Activity
import android.app.LocaleConfig
import android.icu.text.DecimalFormat
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
@ -44,21 +45,23 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.viewModelScope
import net.newpipe.newplayer.Chapter
import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.UIModeState
import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.ui.seeker.ChapterSegment
import net.newpipe.newplayer.ui.seeker.DefaultSeekerColor
import net.newpipe.newplayer.ui.seeker.Seeker
import net.newpipe.newplayer.ui.seeker.SeekerColors
import net.newpipe.newplayer.ui.seeker.SeekerDefaults
import net.newpipe.newplayer.ui.seeker.Segment
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getEmbeddedUiConfig
import net.newpipe.newplayer.utils.getLocale
import net.newpipe.newplayer.utils.getTimeStringFromMs
import java.util.Locale
import kotlin.math.min
private const val TAG = "BottomUI"
@Composable
fun BottomUI(
@ -77,17 +80,17 @@ fun BottomUI(
onValueChange = viewModel::seekPositionChanged,
onValueChangeFinished = viewModel::seekingFinished,
readAheadValue = uiState.bufferedPercentage,
colors = customizedSeekerColors()
colors = customizedSeekerColors(),
chapterSegments = getSeekerSegmentsFromChapters(uiState.chapters, uiState.durationInMs)
)
Text(getTimeStringFromMs(uiState.durationInMs, getLocale() ?: locale))
val embeddedUiConfig = getEmbeddedUiConfig(LocalContext.current as Activity)
IconButton(
onClick = if (uiState.uiMode.fullscreen) viewModel::switchToEmbeddedView
else {
{ // <- head of lambda ... yea kotlin is weird
viewModel.switchToFullscreen(embeddedUiConfig)
viewModel.switchToFullscreen()
}
}
) {
@ -114,6 +117,21 @@ private fun customizedSeekerColors(): SeekerColors {
return colors
}
private fun getSeekerSegmentsFromChapters(chapters: List<Chapter>, duration: Long) =
chapters.map { chapter ->
val markPosition = chapter.chapterStartInMs.toFloat() / duration.toFloat()
if (markPosition < 0f || 1f < markPosition) {
Log.e(
TAG,
"Chapter mark outside of stream duration range: chapter: ${chapter.chapterTitle}, mark in ms: ${chapter.chapterStartInMs}, vidoe duration in ms: ${duration}"
)
ChapterSegment(name = chapter.chapterTitle ?: "", start = 0f)
} else {
ChapterSegment(name = chapter.chapterTitle ?: "", start = markPosition)
}
}
///////////////////////////////////////////////////////////////////
// Preview
///////////////////////////////////////////////////////////////////

View File

@ -51,16 +51,14 @@ import net.newpipe.newplayer.R
import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.playerInternals.PlaylistItem
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.ui.theme.video_player_onSurface
import net.newpipe.newplayer.utils.getEmbeddedUiConfig
@Composable
fun TopUI(
modifier: Modifier, viewModel: VideoPlayerViewModel, uiState: VideoPlayerUIState
) {
val embeddedUiConfig = getEmbeddedUiConfig(activity = LocalContext.current as Activity)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
@ -101,7 +99,7 @@ fun TopUI(
}
AnimatedVisibility(visible = uiState.chapters.isNotEmpty()) {
IconButton(
onClick = { viewModel.openStreamSelection(selectChapter = true, embeddedUiConfig) },
onClick = { viewModel.openStreamSelection(selectChapter = true) },
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.MenuBook,
@ -113,8 +111,7 @@ fun TopUI(
IconButton(
onClick = {
viewModel.openStreamSelection(
selectChapter = false,
embeddedUiConfig
selectChapter = false
)
},
) {

View File

@ -21,7 +21,6 @@
package net.newpipe.newplayer.ui.videoplayer.gesture_ui
import android.app.Activity
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@ -36,13 +35,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import net.newpipe.newplayer.model.VideoPlayerUIState
import net.newpipe.newplayer.model.VideoPlayerViewModel
import net.newpipe.newplayer.model.VideoPlayerViewModelDummy
import net.newpipe.newplayer.ui.theme.VideoPlayerTheme
import net.newpipe.newplayer.utils.getEmbeddedUiConfig
private const val TAG = "EmbeddedGestureUI"
@ -55,8 +52,6 @@ fun EmbeddedGestureUI(
mutableStateOf(false)
}
val embeddedUiConfig = getEmbeddedUiConfig(LocalContext.current as Activity)
val handleMovement = { movement: TouchedPosition ->
Log.d(TAG, "${movement.x}:${movement.y}")
if (0 < movement.y) {
@ -66,7 +61,7 @@ fun EmbeddedGestureUI(
// this check is there to allow a temporary move up in the downward gesture
if (downwardMovementMode == false) {
viewModel.switchToFullscreen(embeddedUiConfig)
viewModel.switchToFullscreen()
} else {
viewModel.embeddedDraggedDown(movement.y)
}

View File

@ -89,25 +89,6 @@ fun getLocale(): Locale? {
return ConfigurationCompat.getLocales(configuration).get(0)
}
@Composable
@ReadOnlyComposable
fun getEmbeddedUiConfig(activity: Activity): EmbeddedUiConfig {
val window = activity.window
val view = LocalView.current
val isLightStatusBar = WindowCompat.getInsetsController(
window,
view
).isAppearanceLightStatusBars
val screenOrientation = activity.requestedOrientation
val defaultBrightness = getDefaultBrightness(activity)
return EmbeddedUiConfig(
systemBarInLightMode = isLightStatusBar,
brightness = defaultBrightness,
screenOrientation = screenOrientation
)
}
@Composable
fun getInsets() =
WindowInsets.systemBars.union(WindowInsets.displayCutout).union(WindowInsets.waterfall)