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( LazyRow(
state = rememberLazyListState(), state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(16.dp),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

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

View file

@ -21,36 +21,32 @@ data class ItemDetailsDialogInfo(
fun ItemDetailsDialog( fun ItemDetailsDialog(
info: ItemDetailsDialogInfo, info: ItemDetailsDialogInfo,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
modifier: Modifier = Modifier, ) = ScrollableDialog(
onDismissRequest = onDismissRequest,
) { ) {
ScrollableDialog( item {
onDismissRequest = onDismissRequest, Text(
modifier = modifier, text = info.title,
) { style = MaterialTheme.typography.titleMedium,
)
}
if (info.overview.isNotNullOrBlank()) {
item { item {
Text( Text(
text = info.title, text = info.overview,
style = MaterialTheme.typography.titleMedium,
)
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.files.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
}
}
items(info.files) { file ->
Text(
text = "- $file",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
} }
if (info.files.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
}
}
items(info.files) { file ->
Text(
text = "- $file",
style = MaterialTheme.typography.bodyMedium,
)
}
} }

View file

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

View file

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

View file

@ -253,7 +253,6 @@ fun SeriesOverview(
ItemDetailsDialog( ItemDetailsDialog(
info = info, info = info,
onDismissRequest = { overviewDialog = null }, onDismissRequest = { overviewDialog = null },
modifier = Modifier,
) )
} }
moreDialog?.let { params -> 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 * 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 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 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.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.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.Column
import androidx.compose.foundation.layout.PaddingValues 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,12 +25,16 @@ 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged 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.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration import kotlin.time.Duration
private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp
@Composable @Composable
fun PlaybackOverlay( fun PlaybackOverlay(
title: String?, title: String?,
@ -80,171 +89,144 @@ fun PlaybackOverlay(
val chapterInteractionSources = val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } 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 density = LocalDensity.current
val titleHeight = val titleHeight =
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp remember {
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight = val subtitleHeight =
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp remember {
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// Calculate height based on content // This will be calculated after composition
// Base height (with or w/o chapters) + title + subtitle var controllerHeight by remember { mutableStateOf(0.dp) }
// The extra 8dp is for padding between title, subtitle, and playback controls var state by remember { mutableStateOf(ViewState.CONTROLLER) }
// 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
}
),
)
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
) { ) {
LazyColumn( AnimatedVisibility(
contentPadding = PaddingValues(bottom = if (chapterRowFocused) 32.dp else 8.dp), state == ViewState.CONTROLLER,
verticalArrangement = Arrangement.spacedBy(8.dp), enter = slideInVertically() + fadeIn(),
modifier = exit = slideOutVertically() + fadeOut(),
Modifier
.padding(bottom = 16.dp)
.height(height)
.fillMaxWidth(),
) { ) {
item { Controller(
title = title,
subtitleStreams = subtitleStreams,
chapters = chapters,
playerControls = playerControls,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
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()) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier, modifier =
) { Modifier
Column( .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(8.dp), .padding(8.dp)
modifier = .onPreviewKeyEvent { e ->
Modifier if (e.type == KeyEventType.KeyUp && isUp(e)) {
.padding(start = 16.dp) state = ViewState.CONTROLLER
.alpha(if (chapterRowFocused) 0f else 1f), true
) {
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),
subtitleStreams = subtitleStreams,
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,
)
}
}
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( false
name = chapter.name, },
position = chapter.position, ) {
imageUrl = chapter.imageUrl, Text(
onClick = { text = "Chapters",
playerControls.seekTo(chapter.position.inWholeMilliseconds) style = MaterialTheme.typography.titleLarge,
controllerViewState.hideControls() )
}, LazyRow(
interactionSource = interactionSource, contentPadding = PaddingValues(16.dp),
modifier = horizontalArrangement = Arrangement.spacedBy(16.dp),
Modifier.ifElse( modifier =
index == 0, Modifier
Modifier.focusRequester(focusRequester), .fillMaxWidth()
), .focusRestorer(focusRequester)
) .onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
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),
),
)
} }
} }
} }
} }
} }
when (state) {
ViewState.CONTROLLER -> {}
ViewState.CHAPTERS -> {}
}
if (seekBarInteractionSource.collectIsFocusedAsState().value) { if (seekBarInteractionSource.collectIsFocusedAsState().value) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
seekProgressPercent = seekProgressPercent =
@ -273,7 +255,7 @@ fun PlaybackOverlay(
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.offsetByPercent( .offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f), xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = height - titleHeight - subtitleHeight), ).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
previewImageUrl = imageUrl, previewImageUrl = imageUrl,
duration = playerControls.duration, duration = playerControls.duration,
seekProgressMs = seekProgressMs, seekProgressMs = seekProgressMs,
@ -284,23 +266,113 @@ fun PlaybackOverlay(
} }
} }
} }
} AnimatedVisibility(
AnimatedVisibility( showDebugInfo && controllerViewState.controlsVisible,
showDebugInfo && controllerViewState.controlsVisible, modifier =
modifier = Modifier
Modifier .align(Alignment.TopStart),
.align(Alignment.TopStart), ) {
) { currentPlayback?.tracks?.letNotEmpty {
currentPlayback?.tracks?.letNotEmpty { PlaybackTrackInfo(
PlaybackTrackInfo( trackSupport = it,
trackSupport = it, modifier =
modifier = Modifier
Modifier .align(Alignment.TopStart)
.align(Alignment.TopStart) .padding(16.dp)
.padding(16.dp) .background(AppColors.TransparentBlack50),
.background(AppColors.TransparentBlack50), )
) }
} }
} }
} }
} }
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.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
@ -113,7 +112,9 @@ fun IntervalSeekBarImpl(
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
var hasSeeked by remember { mutableStateOf(false) } var hasSeeked by remember { mutableStateOf(false) }
var seekPositionMs by remember { mutableLongStateOf((progress * durationMs).toLong()) } 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) { LaunchedEffect(isFocused) {
if (!isFocused) hasSeeked = false if (!isFocused) hasSeeked = false