Better playback overlay appearance animations (#1130)

## Description
Updates the playback overlay so that individual component animate in
more deliberately.

For example, the logo & clock slide in from the top instead of the
middle of the screen. And the trick play preview now expands and shrinks
vertically.

### Related issues
I thought there was an issue about this, but I can't find it

### Testing
Emulator, nvidia shield

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-23 18:04:11 -04:00 committed by GitHub
parent 920733075b
commit 8a37d63d09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 95 additions and 74 deletions

View file

@ -1,10 +1,17 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -50,6 +57,9 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.media3.common.Player import androidx.media3.common.Player
@ -119,17 +129,14 @@ fun PlaybackOverlay(
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val density = LocalDensity.current val density = LocalDensity.current
val titleHeight = val titleHeight =
remember { remember(item?.title) {
if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
} }
val subtitleHeight = val subtitleHeight =
remember { remember(item?.subtitleLong) {
if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
} }
@ -137,19 +144,6 @@ fun PlaybackOverlay(
var controllerHeight by remember { mutableStateOf(0.dp) } var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
@ -160,6 +154,18 @@ fun PlaybackOverlay(
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
) { ) {
// Background scrim for OSD readability
val scrimBrush =
remember {
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.80f),
),
)
}
Box( Box(
modifier = modifier =
Modifier Modifier
@ -169,10 +175,16 @@ fun PlaybackOverlay(
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.CONTROLLER, visible = controllerViewState.controlsVisible && state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically() + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
if (seekBarFocused) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
val nextState = val nextState =
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS OverlayViewState.CHAPTERS
@ -253,11 +265,13 @@ fun PlaybackOverlay(
} }
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.CHAPTERS, visible = controllerViewState.controlsVisible && state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex = val chapterIndex =
remember { remember {
@ -368,7 +382,7 @@ fun PlaybackOverlay(
} }
} }
AnimatedVisibility( AnimatedVisibility(
state == OverlayViewState.QUEUE, visible = controllerViewState.controlsVisible && state == OverlayViewState.QUEUE,
enter = slideInVertically { it / 2 } + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
@ -441,15 +455,17 @@ fun PlaybackOverlay(
} }
} }
if (seekBarInteractionSource.collectIsFocusedAsState().value) { // Trickplay
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
AnimatedVisibility( AnimatedVisibility(
seekProgressPercent >= 0 && seekBarFocused, visible = controllerViewState.controlsVisible && seekProgressPercent >= 0 && seekBarFocused,
enter =
expandVertically(
spring(
stiffness = Spring.StiffnessMedium,
visibilityThreshold = IntSize.VisibilityThreshold,
),
) + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) { ) {
Box( Box(
modifier = modifier =
@ -496,9 +512,13 @@ fun PlaybackOverlay(
} }
} }
} }
// Top
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility( AnimatedVisibility(
!showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
@ -514,7 +534,9 @@ fun PlaybackOverlay(
) )
} }
AnimatedVisibility( AnimatedVisibility(
!showDebugInfo && showClock && controllerViewState.controlsVisible, visible = !showDebugInfo && showClock && controllerViewState.controlsVisible,
enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(),
exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd), .align(Alignment.TopEnd),
@ -522,7 +544,9 @@ fun PlaybackOverlay(
TimeDisplay() TimeDisplay()
} }
AnimatedVisibility( AnimatedVisibility(
showDebugInfo && controllerViewState.controlsVisible, visible = showDebugInfo && controllerViewState.controlsVisible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
@ -578,6 +602,11 @@ fun Controller(
val verticalOffset by animateDpAsState( val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp, targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset", label = "TitleBumpOffset",
animationSpec =
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold,
),
) )
Column( Column(

View file

@ -5,8 +5,6 @@ import androidx.annotation.Dimension
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -364,44 +362,38 @@ fun PlaybackPageContent(
} }
// The playback controls // The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible, PlaybackOverlay(
Modifier, modifier =
slideInVertically { it }, Modifier
slideOutVertically { it }, .padding(WindowInsets.systemBars.asPaddingValues())
) { .fillMaxSize()
PlaybackOverlay( .background(Color.Transparent),
modifier = item = currentPlayback?.item,
Modifier playerControls = player,
.padding(WindowInsets.systemBars.asPaddingValues()) controllerViewState = controllerViewState,
.fillMaxSize() showPlay = playPauseState.showPlay,
.background(Color.Transparent), previousEnabled = true,
item = currentPlayback?.item, nextEnabled = playlist.hasNext(),
playerControls = player, seekEnabled = true,
controllerViewState = controllerViewState, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
showPlay = playPauseState.showPlay, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
previousEnabled = true, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
nextEnabled = playlist.hasNext(), onPlaybackActionClick = onPlaybackActionClick,
seekEnabled = true, onClickPlaybackDialogType = { playbackDialog = it },
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, onSeekBarChange = seekBarState::onValueChange,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, showDebugInfo = showDebugInfo,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, currentPlayback = currentPlayback,
onPlaybackActionClick = onPlaybackActionClick, chapters = mediaInfo?.chapters ?: listOf(),
onClickPlaybackDialogType = { playbackDialog = it }, trickplayInfo = mediaInfo?.trickPlayInfo,
onSeekBarChange = seekBarState::onValueChange, trickplayUrlFor = viewModel::getTrickplayUrl,
showDebugInfo = showDebugInfo, playlist = playlist,
currentPlayback = currentPlayback, onClickPlaylist = {
chapters = mediaInfo?.chapters ?: listOf(), viewModel.playItemInPlaylist(it)
trickplayInfo = mediaInfo?.trickPlayInfo, },
trickplayUrlFor = viewModel::getTrickplayUrl, currentSegment = currentSegment?.segment,
playlist = playlist, showClock = preferences.appPreferences.interfacePreferences.showClock,
onClickPlaylist = { )
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
val subtitleSettings = val subtitleSettings =
remember(mediaInfo) { remember(mediaInfo) {