mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Show chapters & layout playback overlay
This commit is contained in:
parent
9a588f38b4
commit
f3df36e1a1
2 changed files with 206 additions and 56 deletions
|
|
@ -75,6 +75,7 @@ fun PlaybackContent(
|
||||||
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
|
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
|
||||||
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
|
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
|
||||||
val trickplay by viewModel.trickplay.observeAsState(null)
|
val trickplay by viewModel.trickplay.observeAsState(null)
|
||||||
|
val chapters by viewModel.chapters.observeAsState(listOf())
|
||||||
|
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
// TODO loading
|
// TODO loading
|
||||||
|
|
@ -238,6 +239,7 @@ fun PlaybackContent(
|
||||||
audioStreams = audioStreams,
|
audioStreams = audioStreams,
|
||||||
trickplayInfo = trickplay,
|
trickplayInfo = trickplay,
|
||||||
trickplayUrlFor = viewModel::getTrickplayUrl,
|
trickplayUrlFor = viewModel::getTrickplayUrl,
|
||||||
|
chapters = chapters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,43 @@
|
||||||
package com.github.damontecres.dolphin.ui.playback
|
package com.github.damontecres.dolphin.ui.playback
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.dolphin.data.model.Chapter
|
||||||
|
import com.github.damontecres.dolphin.ui.cards.ChapterCard
|
||||||
|
import com.github.damontecres.dolphin.ui.ifElse
|
||||||
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -24,6 +45,7 @@ import kotlin.time.Duration
|
||||||
fun PlaybackOverlay(
|
fun PlaybackOverlay(
|
||||||
title: String?,
|
title: String?,
|
||||||
subtitleStreams: List<SubtitleStream>,
|
subtitleStreams: List<SubtitleStream>,
|
||||||
|
chapters: List<Chapter>,
|
||||||
playerControls: Player,
|
playerControls: Player,
|
||||||
controllerViewState: ControllerViewState,
|
controllerViewState: ControllerViewState,
|
||||||
showPlay: Boolean,
|
showPlay: Boolean,
|
||||||
|
|
@ -47,62 +69,90 @@ fun PlaybackOverlay(
|
||||||
trickplayUrlFor: (Int) -> String? = { null },
|
trickplayUrlFor: (Int) -> String? = { null },
|
||||||
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
// Will be used for preview/trick play images
|
|
||||||
var seekProgressMs by remember { mutableLongStateOf(-1L) }
|
|
||||||
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
|
||||||
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
|
||||||
|
// Will be used for preview/trick play images
|
||||||
|
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
|
||||||
|
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
|
||||||
|
|
||||||
|
val chapterInteractionSources =
|
||||||
|
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
|
||||||
|
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
|
||||||
|
var chapterRowFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val titleTextStyle = MaterialTheme.typography.displaySmall
|
||||||
|
val subtitleTextStyle = MaterialTheme.typography.headlineMedium
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val titleHeight =
|
||||||
|
if (title.isNotNullOrBlank()) with(density) { titleTextStyle.fontSize.toDp() } else 0.dp
|
||||||
|
val subtitleHeight =
|
||||||
|
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextStyle.fontSize.toDp() } else 0.dp
|
||||||
|
|
||||||
|
// Calculate height based on content
|
||||||
|
// Base height (with or w/o chapters) + title + subtitle
|
||||||
|
// The extra 8dp is for padding between title, subtitle, and playback controls
|
||||||
|
// When chapter row is focused, the title/subtitle/playback controls will be hidden, but need extra height for the chapter images
|
||||||
|
val height by animateDpAsState(
|
||||||
|
(if (chapters.isNotEmpty()) 184.dp else 140.dp) +
|
||||||
|
(if (chapterRowFocused) 40.dp else 0.dp) +
|
||||||
|
(
|
||||||
|
if (!chapterRowFocused && title.isNotNullOrBlank()) {
|
||||||
|
titleHeight + 8.dp
|
||||||
|
} else {
|
||||||
|
0.dp
|
||||||
|
}
|
||||||
|
) +
|
||||||
|
(
|
||||||
|
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
|
||||||
|
subtitleHeight + 8.dp
|
||||||
|
} else {
|
||||||
|
0.dp
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter,
|
||||||
) {
|
) {
|
||||||
Column {
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(bottom = if (chapterRowFocused) 32.dp else 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.height(height)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
.alpha(if (chapterRowFocused) 0f else 1f),
|
||||||
|
) {
|
||||||
title?.let {
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
style = titleTextStyle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
style = subtitleTextStyle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
seekProgressPercent =
|
|
||||||
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
|
||||||
}
|
}
|
||||||
}
|
PlaybackControls(
|
||||||
if (trickplayInfo != null) {
|
|
||||||
AnimatedVisibility(seekProgressPercent >= 0 && seekBarFocused) {
|
|
||||||
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
|
||||||
val index =
|
|
||||||
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
|
||||||
val imageUrl = trickplayUrlFor(index)
|
|
||||||
|
|
||||||
if (imageUrl != null) {
|
|
||||||
SeekPreviewImage(
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
// .align(Alignment.TopStart)
|
.fillMaxWidth()
|
||||||
.offsetByPercent(
|
.alpha(if (chapterRowFocused) 0f else 1f),
|
||||||
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
|
||||||
// yOffset = heightPx,
|
|
||||||
// yPercentage = 1 - controlHeight,
|
|
||||||
),
|
|
||||||
previewImageUrl = imageUrl,
|
|
||||||
duration = playerControls.duration,
|
|
||||||
seekProgressMs = seekProgressMs,
|
|
||||||
videoWidth = trickplayInfo.width,
|
|
||||||
videoHeight = trickplayInfo.height,
|
|
||||||
trickPlayInfo = trickplayInfo,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaybackControls(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
subtitleStreams = subtitleStreams,
|
subtitleStreams = subtitleStreams,
|
||||||
playerControls = playerControls,
|
playerControls = playerControls,
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
|
|
@ -129,4 +179,102 @@ fun PlaybackOverlay(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chapters.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// .offset(y = 200.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Chapters",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRestorer(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.hasFocus) {
|
||||||
|
controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
chapterRowFocused = it.hasFocus || it.isFocused
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
itemsIndexed(chapters) { index, chapter ->
|
||||||
|
val interactionSource = chapterInteractionSources[index]
|
||||||
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) controllerViewState.pulseControls()
|
||||||
|
}
|
||||||
|
ChapterCard(
|
||||||
|
name = chapter.name,
|
||||||
|
position = chapter.position,
|
||||||
|
imageUrl = chapter.imageUrl,
|
||||||
|
onClick = {
|
||||||
|
playerControls.seekTo(chapter.position.inWholeMilliseconds)
|
||||||
|
controllerViewState.hideControls()
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
seekProgressPercent =
|
||||||
|
(playerControls.currentPosition.toFloat() / playerControls.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trickplayInfo != null) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
seekProgressPercent >= 0 && seekBarFocused,
|
||||||
|
) {
|
||||||
|
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||||
|
val index =
|
||||||
|
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
|
||||||
|
val imageUrl = trickplayUrlFor(index)
|
||||||
|
|
||||||
|
if (imageUrl != null) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.fillMaxWidth(.95f),
|
||||||
|
) {
|
||||||
|
SeekPreviewImage(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.offsetByPercent(
|
||||||
|
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
|
||||||
|
).padding(bottom = height - titleHeight - subtitleHeight),
|
||||||
|
previewImageUrl = imageUrl,
|
||||||
|
duration = playerControls.duration,
|
||||||
|
seekProgressMs = seekProgressMs,
|
||||||
|
videoWidth = trickplayInfo.width,
|
||||||
|
videoHeight = trickplayInfo.height,
|
||||||
|
trickPlayInfo = trickplayInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue