Yet another rewrite of the playback overlay

This commit is contained in:
Damontecres 2025-10-07 17:51:09 -04:00
parent 88038bfcfd
commit 005d6b11ce
No known key found for this signature in database
9 changed files with 268 additions and 199 deletions

View file

@ -35,7 +35,7 @@ fun ChapterRow(
LazyRow(
state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.padding(start = 16.dp)

View file

@ -233,7 +233,6 @@ fun DialogPopup(
@Composable
fun ScrollableDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: LazyListScope.() -> Unit,
) {
val scrollAmount = 100f
@ -258,7 +257,7 @@ fun ScrollableDialog(
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
modifier =
modifier
Modifier
.width(600.dp)
.heightIn(max = 380.dp)
.focusable()

View file

@ -21,11 +21,8 @@ data class ItemDetailsDialogInfo(
fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
ScrollableDialog(
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
item {
Text(
@ -53,4 +50,3 @@ fun ItemDetailsDialog(
)
}
}
}

View file

@ -137,7 +137,6 @@ fun SeriesDetails(
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
modifier = Modifier,
)
}
}
@ -202,7 +201,7 @@ fun SeriesDetailsContent(
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
modifier = Modifier,
) {
item {
SeriesDetailsHeader(

View file

@ -208,7 +208,6 @@ fun MovieDetails(
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
modifier = Modifier,
)
}
moreDialog?.let { params ->

View file

@ -253,7 +253,6 @@ fun SeriesOverview(
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
modifier = Modifier,
)
}
moreDialog?.let { params ->

View file

@ -49,3 +49,7 @@ fun isForwardButton(event: KeyEvent): Boolean =
* Whether the [KeyEvent] is a key up event pressing media play or media play/pause
*/
fun isPlayKeyUp(key: KeyEvent) = key.type == KeyEventType.KeyUp && (key.key == Key.MediaPlay || key.key == Key.MediaPlayPause)
fun isUp(event: KeyEvent): Boolean = event.key == Key.DirectionUp || event.key == Key.SystemNavigationUp
fun isDown(event: KeyEvent): Boolean = event.key == Key.DirectionDown || event.key == Key.SystemNavigationDown

View file

@ -1,7 +1,10 @@
package com.github.damontecres.dolphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -10,9 +13,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
@ -24,12 +25,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.input.key.KeyEventType
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -42,9 +47,13 @@ import com.github.damontecres.dolphin.ui.cards.ChapterCard
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration
private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp
@Composable
fun PlaybackOverlay(
title: String?,
@ -80,130 +89,99 @@ fun PlaybackOverlay(
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
var chapterRowFocused by remember { mutableStateOf(false) }
val titleTextSize = 28.sp
val subtitleTextSize = 18.sp
val density = LocalDensity.current
val titleHeight =
remember {
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember {
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.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(
96.dp +
(if (chapters.isNotEmpty()) 40.dp else 0.dp) +
(if (chapterRowFocused) 80.dp else 0.dp) +
(
if (!chapterRowFocused && title.isNotNullOrBlank()) {
titleHeight + 12.dp
} else {
0.dp
}
) +
(
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
subtitleHeight + 12.dp
} else {
0.dp
}
),
)
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(ViewState.CONTROLLER) }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
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(),
AnimatedVisibility(
state == ViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.alpha(if (chapterRowFocused) 0f else 1f),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
)
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
)
}
}
PlaybackControls(
modifier =
Modifier
.fillMaxWidth()
.alpha(if (chapterRowFocused) 0f else 1f),
Controller(
title = title,
subtitleStreams = subtitleStreams,
chapters = chapters,
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo,
onSeekProgress = {
seekProgressMs = it
onSeekBarChange(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentPlayback?.subtitleIndex,
audioIndex = currentPlayback?.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onSeekProgress = {
onSeekBarChange(it)
seekProgressMs = it
},
showDebugInfo = showDebugInfo,
scale = scale,
playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback,
audioStreams = audioStreams,
subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource,
modifier =
Modifier
.onKeyEvent { e ->
if (chapters.isNotEmpty() &&
e.type == KeyEventType.KeyDown && isDown(e) &&
!seekBarFocused
) {
state = ViewState.CHAPTERS
true
}
false
}.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
)
}
}
AnimatedVisibility(
state == ViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
item {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
// .offset(y = 200.dp)
.padding(8.dp),
.padding(8.dp)
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = ViewState.CONTROLLER
true
}
false
},
) {
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
)
val focusRequester = remember { FocusRequester() }
LazyRow(
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -215,7 +193,6 @@ fun PlaybackOverlay(
if (it.hasFocus) {
controllerViewState.pulseControls()
}
chapterRowFocused = it.hasFocus || it.isFocused
},
) {
itemsIndexed(chapters) { index, chapter ->
@ -244,7 +221,12 @@ fun PlaybackOverlay(
}
}
}
when (state) {
ViewState.CONTROLLER -> {}
ViewState.CHAPTERS -> {}
}
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
LaunchedEffect(Unit) {
seekProgressPercent =
@ -273,7 +255,7 @@ fun PlaybackOverlay(
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = height - titleHeight - subtitleHeight),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
previewImageUrl = imageUrl,
duration = playerControls.duration,
seekProgressMs = seekProgressMs,
@ -284,7 +266,6 @@ fun PlaybackOverlay(
}
}
}
}
AnimatedVisibility(
showDebugInfo && controllerViewState.controlsVisible,
modifier =
@ -304,3 +285,94 @@ fun PlaybackOverlay(
}
}
}
}
enum class ViewState {
CONTROLLER,
CHAPTERS,
}
@Composable
fun Controller(
title: String?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onSeekProgress: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
)
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
)
}
}
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
subtitleStreams = subtitleStreams,
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentPlayback?.subtitleIndex,
audioIndex = currentPlayback?.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
)
if (chapters.isNotEmpty()) {
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}

View file

@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
@ -113,7 +112,9 @@ fun IntervalSeekBarImpl(
val isFocused by interactionSource.collectIsFocusedAsState()
var hasSeeked by remember { mutableStateOf(false) }
var seekPositionMs by remember { mutableLongStateOf((progress * durationMs).toLong()) }
val progressToUse by remember { derivedStateOf { if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong() } }
// val progressToUse by remember { derivedStateOf { if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong() } }
val progressToUse =
if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong()
LaunchedEffect(isFocused) {
if (!isFocused) hasSeeked = false