From 005d6b11cec4a3b508b4f7c778eec70cf180c16f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 7 Oct 2025 17:51:09 -0400 Subject: [PATCH] Yet another rewrite of the playback overlay --- .../dolphin/ui/cards/ChapterRow.kt | 2 +- .../dolphin/ui/components/Dialogs.kt | 3 +- .../dolphin/ui/data/ItemDetailsDialogInfo.kt | 46 +- .../dolphin/ui/detail/SeriesDetails.kt | 3 +- .../dolphin/ui/detail/movie/MovieDetails.kt | 1 - .../ui/detail/series/SeriesOverview.kt | 1 - .../dolphin/ui/playback/KeyIdentifier.kt | 4 + .../dolphin/ui/playback/PlaybackOverlay.kt | 402 +++++++++++------- .../dolphin/ui/playback/SeekBar.kt | 5 +- 9 files changed, 268 insertions(+), 199 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterRow.kt index c540d6a5..137f3541 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterRow.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index d1ba454a..3ac10124 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/data/ItemDetailsDialogInfo.kt index c3bb37f5..11b8863b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/data/ItemDetailsDialogInfo.kt @@ -21,36 +21,32 @@ data class ItemDetailsDialogInfo( fun ItemDetailsDialog( info: ItemDetailsDialogInfo, onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, +) = ScrollableDialog( + onDismissRequest = onDismissRequest, ) { - ScrollableDialog( - onDismissRequest = onDismissRequest, - modifier = modifier, - ) { + item { + Text( + text = info.title, + style = MaterialTheme.typography.titleMedium, + ) + } + if (info.overview.isNotNullOrBlank()) { item { Text( - text = info.title, - 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", + 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, + ) + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 1e4c1d12..be9c9f06 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt index a6961e41..dde4faf5 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt @@ -208,7 +208,6 @@ fun MovieDetails( ItemDetailsDialog( info = info, onDismissRequest = { overviewDialog = null }, - modifier = Modifier, ) } moreDialog?.let { params -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 9cbc3964..83bfcf34 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -253,7 +253,6 @@ fun SeriesOverview( ItemDetailsDialog( info = info, onDismissRequest = { overviewDialog = null }, - modifier = Modifier, ) } moreDialog?.let { params -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt index 197b61d9..b2294684 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index f3013ab8..907c7d19 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -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,171 +89,144 @@ 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 = - if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp + remember { + if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp + } 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 - // 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 { + 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( 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), - 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() + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp && isUp(e)) { + state = ViewState.CONTROLLER + true } - 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), - ), - ) + false + }, + ) { + Text( + text = "Chapters", + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .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) { 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,23 +266,113 @@ fun PlaybackOverlay( } } } - } - AnimatedVisibility( - showDebugInfo && controllerViewState.controlsVisible, - modifier = - Modifier - .align(Alignment.TopStart), - ) { - currentPlayback?.tracks?.letNotEmpty { - PlaybackTrackInfo( - trackSupport = it, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(16.dp) - .background(AppColors.TransparentBlack50), - ) + AnimatedVisibility( + showDebugInfo && controllerViewState.controlsVisible, + modifier = + Modifier + .align(Alignment.TopStart), + ) { + currentPlayback?.tracks?.letNotEmpty { + PlaybackTrackInfo( + trackSupport = it, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(16.dp) + .background(AppColors.TransparentBlack50), + ) + } } } } } + +enum class ViewState { + CONTROLLER, + CHAPTERS, +} + +@Composable +fun Controller( + title: String?, + subtitleStreams: List, + chapters: List, + 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, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt index c3cc6791..50f65b36 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt @@ -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