mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
fix(player): smooth the expanded player swipe
Read sheet progress in draw/derived scopes instead of composition so a drag no longer recomposes the player every frame, keep the expanded player, floating player and bottom bar composed once shown instead of building them mid-drag, and settle with a spring that carries fling velocity. Add a drag test for CompactPlayerOverlay.
This commit is contained in:
parent
bba725a139
commit
9f4ce11509
@ -32,17 +32,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
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.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -299,6 +305,15 @@ internal fun CompactPlayerOverlay(
|
|||||||
onAlternativeSource: () -> Unit,
|
onAlternativeSource: () -> Unit,
|
||||||
onExpandLyrics: () -> Unit,
|
onExpandLyrics: () -> Unit,
|
||||||
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
|
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
|
||||||
|
expandedPlayer: @Composable (modifier: Modifier, onCollapse: () -> Unit) -> Unit = { modifier, onCollapse ->
|
||||||
|
AppExpandedPlayer(
|
||||||
|
modifier = modifier,
|
||||||
|
onCollapse = onCollapse,
|
||||||
|
onQueue = onQueue,
|
||||||
|
onAlternativeSource = onAlternativeSource,
|
||||||
|
onExpandLyrics = onExpandLyrics
|
||||||
|
)
|
||||||
|
},
|
||||||
bottomBar: @Composable (Modifier) -> Unit = {
|
bottomBar: @Composable (Modifier) -> Unit = {
|
||||||
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
||||||
},
|
},
|
||||||
@ -325,16 +340,39 @@ internal fun CompactPlayerOverlay(
|
|||||||
val sheetState = key(peekHeight) {
|
val sheetState = key(peekHeight) {
|
||||||
rememberBottomSheetState(
|
rememberBottomSheetState(
|
||||||
initialDetent = partialDetent,
|
initialDetent = partialDetent,
|
||||||
detents = listOf(partialDetent, SheetDetent.FullyExpanded)
|
detents = listOf(partialDetent, SheetDetent.FullyExpanded),
|
||||||
|
// A spring carries the fling velocity into the settle, so releasing mid-drag
|
||||||
|
// glides to the nearest detent instead of stalling and then easing like a tween.
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val progress = if (!sheetState.offset.isFinite() ||
|
// Read only inside draw/derived scopes: the sheet offset changes every drag frame, and
|
||||||
|
// reading it in composition would recompose the whole player on each one.
|
||||||
|
val progress by remember(sheetState, partialDetent) {
|
||||||
|
derivedStateOf {
|
||||||
|
val value = if (!sheetState.offset.isFinite() ||
|
||||||
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
|
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
|
||||||
) 0f else runCatching {
|
) 0f else runCatching {
|
||||||
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
||||||
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
||||||
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
|
value.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keyed on the sheet state: it is recreated when the peek height changes, and a derived
|
||||||
|
// value left reading the previous sheet's progress would never update again.
|
||||||
|
val expandedPlayerVisible by remember(sheetState) {
|
||||||
|
derivedStateOf { viewModel.compactSheetUiState(progress).showExpandedPlayer }
|
||||||
|
}
|
||||||
|
val floatingPlayerVisible by remember(sheetState) {
|
||||||
|
derivedStateOf { viewModel.compactSheetUiState(progress).floatingPlayerAlpha > 0.01f }
|
||||||
|
}
|
||||||
|
// Once shown, keep the expanded player composed: composing it (view models, lyrics, cover
|
||||||
|
// art) on the first drag frame of every gesture is the hitch the user feels as lag.
|
||||||
|
var expandedPlayerComposed by remember { mutableStateOf(false) }
|
||||||
|
if (expandedPlayerVisible) expandedPlayerComposed = true
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
content()
|
content()
|
||||||
@ -345,44 +383,52 @@ internal fun CompactPlayerOverlay(
|
|||||||
) {
|
) {
|
||||||
Sheet(modifier = Modifier.fillMaxSize()) {
|
Sheet(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (uiState.showExpandedPlayer) {
|
if (expandedPlayerComposed) {
|
||||||
AppExpandedPlayer(
|
expandedPlayer(
|
||||||
modifier = Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer { alpha = uiState.expandedPlayerAlpha },
|
.graphicsLayer {
|
||||||
onCollapse = {
|
alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha
|
||||||
scope.launch { sheetState.animateTo(partialDetent) }
|
|
||||||
},
|
},
|
||||||
onQueue = onQueue,
|
{ scope.launch { sheetState.animateTo(partialDetent) } }
|
||||||
onAlternativeSource = onAlternativeSource,
|
|
||||||
onExpandLyrics = onExpandLyrics
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
// Always composed: building it (view model, cover art, player state) only once
|
||||||
|
// progress drops below the fade-out point stalls the collapse mid-drag.
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
floatingPlayer(
|
floatingPlayer(
|
||||||
Modifier
|
Modifier
|
||||||
|
.zeroSizeUnless(floatingPlayerVisible)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = uiState.floatingPlayerAlpha
|
alpha = viewModel.floatingPlayerAlpha(progress)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
|
||||||
bottomBar(
|
bottomBar(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
|
.zeroSizeUnless(floatingPlayerVisible)
|
||||||
.onSizeChanged { size ->
|
.onSizeChanged { size ->
|
||||||
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = uiState.floatingPlayerAlpha
|
alpha = viewModel.floatingPlayerAlpha(progress)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the content composed but takes it out of layout, drawing and hit testing while it is
|
||||||
|
* fully faded out, so it neither steals touches from what is above it nor costs a re-composition
|
||||||
|
* when it comes back.
|
||||||
|
*/
|
||||||
|
private fun Modifier.zeroSizeUnless(visible: Boolean): Modifier =
|
||||||
|
if (visible) this else layout { measurable, constraints ->
|
||||||
|
measurable.measure(constraints)
|
||||||
|
layout(0, 0) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
package org.spotube.spotube.modules.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.PointerType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.use
|
||||||
|
import org.spotube.spotube.core.navigation.Navigator
|
||||||
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
|
import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
|
import org.spotube.spotube.core.navigation.rememberNavigationState
|
||||||
|
import org.spotube.spotube.core.ui.theming.SpotubeTheme
|
||||||
|
import org.spotube.spotube.modules.settings.Theme
|
||||||
|
import org.spotube.spotube.modules.settings.UserSettings
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
class CompactPlayerOverlayDragTest {
|
||||||
|
@Test
|
||||||
|
fun draggingTheSheetUpShowsTheExpandedPlayer() {
|
||||||
|
var expandedComposed = false
|
||||||
|
var floatingCompositions = 0
|
||||||
|
var floatingPlaced = false
|
||||||
|
val width = 400
|
||||||
|
val height = 800
|
||||||
|
ImageComposeScene(width, height) {
|
||||||
|
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||||
|
val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES)
|
||||||
|
val navigator = remember(navigation) { Navigator(navigation) }
|
||||||
|
CompactPlayerOverlay(
|
||||||
|
navigator = navigator,
|
||||||
|
navigationState = navigation,
|
||||||
|
viewModel = remember { AppShellViewModel() },
|
||||||
|
onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {},
|
||||||
|
floatingPlayer = { modifier ->
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
floatingCompositions++
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier.fillMaxWidth().height(64.dp).background(Color.Gray)
|
||||||
|
.onGloballyPositioned { floatingPlaced = true })
|
||||||
|
},
|
||||||
|
expandedPlayer = { modifier, _ ->
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
expandedComposed = true
|
||||||
|
onDispose { expandedComposed = false }
|
||||||
|
}
|
||||||
|
Box(modifier.fillMaxSize().background(Color.Red))
|
||||||
|
},
|
||||||
|
bottomBar = { modifier ->
|
||||||
|
Box(modifier.fillMaxWidth().height(56.dp).background(Color.Blue))
|
||||||
|
}
|
||||||
|
) { Box(Modifier.fillMaxSize()) }
|
||||||
|
}
|
||||||
|
}.use { scene ->
|
||||||
|
var t = 0L
|
||||||
|
fun frame() { scene.render(t); t += 16_000_000L }
|
||||||
|
repeat(10) { frame() }
|
||||||
|
assertTrue(floatingPlaced, "floating player should show while collapsed")
|
||||||
|
assertFalse(expandedComposed, "expanded player should not be composed while collapsed")
|
||||||
|
|
||||||
|
// grab the floating player (just above the bottom bar) and drag to the top
|
||||||
|
val startY = height - 120f + 20f
|
||||||
|
val start = Offset(width / 2f, startY)
|
||||||
|
scene.sendPointerEvent(PointerEventType.Press, start, type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
repeat(5) { frame() }
|
||||||
|
var y = startY
|
||||||
|
repeat(30) {
|
||||||
|
y -= (startY - 40f) / 30f
|
||||||
|
scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
frame()
|
||||||
|
}
|
||||||
|
scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
repeat(120) { frame() }
|
||||||
|
|
||||||
|
assertTrue(expandedComposed, "expanded player should be composed after dragging up")
|
||||||
|
// out of layout while the expanded player covers it, but never re-composed
|
||||||
|
floatingPlaced = false
|
||||||
|
repeat(5) { frame() }
|
||||||
|
assertFalse(floatingPlaced, "floating player should be out of layout when fully expanded")
|
||||||
|
|
||||||
|
// drag back down: the floating player must return without being composed again
|
||||||
|
val downStart = Offset(width / 2f, 60f)
|
||||||
|
scene.sendPointerEvent(PointerEventType.Press, downStart, type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
repeat(5) { frame() }
|
||||||
|
var dy = downStart.y
|
||||||
|
repeat(30) {
|
||||||
|
dy += (height - 160f) / 30f
|
||||||
|
scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
frame()
|
||||||
|
}
|
||||||
|
scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L)
|
||||||
|
repeat(120) { frame() }
|
||||||
|
assertTrue(floatingPlaced, "floating player should be back after collapsing")
|
||||||
|
assertTrue(floatingCompositions == 1, "floating player was composed $floatingCompositions times, expected 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user