From 568bba64ef0588b8d3033a239d8b329d840fdf6b Mon Sep 17 00:00:00 2001 From: Luis Vervaet Date: Tue, 14 Jul 2026 22:48:22 -0400 Subject: [PATCH 1/2] fix layout issues --- .../ui/mainActivity/MainActivity.kt | 65 ++++++++++--------- .../components/BottomMiniPlayer.kt | 4 +- .../ui/nowplaying/ExpandingNowPlayer.kt | 21 +++++- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt index dea2528..234728e 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/MainActivity.kt @@ -795,6 +795,40 @@ class MainActivity : ComponentActivity() { ) } + // Phone Now Playing lives here — a SECOND child of the drawer content, stacked over the + // scaffold, so the settings drawer sheet draws OVER it. It used to be a sibling of the + // whole drawer and painted on top of the opened settings sheet. Re-wrapped in + // ReverseLayoutDirection because the entire drawer is flipped to sit on the right edge. + if (!isWide) { + // When playback ends, snap back to the mini bar so the next song opens collapsed. + LaunchedEffect(playerState?.currentMediaItem?.mediaId) { + if (playerState?.currentMediaItem?.mediaId == null) { + expandingPlayerState.snapToCollapsed() + } + } + ReverseLayoutDirection { + AnimatedVisibility( + visible = playerState?.currentMediaItem?.mediaId != null, + // Slide the collapsed mini bar in from the side; exit is instant (the animated stop + // already slides it away before it disappears). + enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }), + exit = ExitTransition.None + ) { + ExpandingNowPlayer( + state = expandingPlayerState, + playerState = playerState, + palette = mainActivityViewModel?.palette, + colorList = mainActivityViewModel?.colorList, + nowPlaying = { mainActivityViewModel?.nowPlayingData?.staticData?.value?.nowPlaying }, + play = { mediaController?.play() }, + pause = { mediaController?.pause() }, + stop = { mainActivityViewModel?.sharedMediaController?.mediaSession?.value?.player?.stop() }, + onDismissProgress = { miniDismissProgress = it } + ) + } + } + } + } } @@ -815,37 +849,6 @@ class MainActivity : ComponentActivity() { } } - // Phone Now Playing: a single surface that grows from the mini bar into the full screen. - // Rendered last so it overlays the app; the collapsed bar sits above the floating nav bar. - // Tablets keep the side pane. - if (!isWide) { - // When playback ends, snap the surface back to the mini bar so the next song opens as the - // mini bar (sliding in) rather than reappearing full-screen where it was left. - LaunchedEffect(playerState?.currentMediaItem?.mediaId) { - if (playerState?.currentMediaItem?.mediaId == null) { - expandingPlayerState.snapToCollapsed() - } - } - AnimatedVisibility( - visible = playerState?.currentMediaItem?.mediaId != null, - // Slide the (collapsed) mini bar in from the side, like the standalone mini player did. - // Exit is instant — the animated stop already slides the bar away before it disappears. - enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }), - exit = ExitTransition.None - ) { - ExpandingNowPlayer( - state = expandingPlayerState, - playerState = playerState, - palette = mainActivityViewModel?.palette, - colorList = mainActivityViewModel?.colorList, - nowPlaying = { mainActivityViewModel?.nowPlayingData?.staticData?.value?.nowPlaying }, - play = { mediaController?.play() }, - pause = { mediaController?.pause() }, - stop = { mainActivityViewModel?.sharedMediaController?.mediaSession?.value?.player?.stop() }, - onDismissProgress = { miniDismissProgress = it } - ) - } - } } } } diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/components/BottomMiniPlayer.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/components/BottomMiniPlayer.kt index 3280d98..03cf754 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/components/BottomMiniPlayer.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/mainActivity/components/BottomMiniPlayer.kt @@ -191,8 +191,10 @@ fun MiniPlayerContent( modifier = modifier .fillMaxSize() .clickable( + // No ripple on the mini player itself — tapping it expands/opens the player; the ripple felt + // heavy on the whole bar. (The play/pause button below keeps its own ripple.) interactionSource = remember { MutableInteractionSource() }, - indication = ripple() + indication = null ) { showNowPlaying() } .padding(start = 8.dp) ) { diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt index 0e03019..dfd0973 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt @@ -65,7 +65,14 @@ private val NavBarArea: Dp = 82.dp /** Container corner radius / shadow at rest; both flatten to nothing as it fills the screen. */ private val CollapsedCorner: Dp = 24.dp -private val CollapsedShadow: Dp = 4.dp +private val CollapsedShadow: Dp = 2.dp + +/** + * The collapsed mini bar's BOTTOM corners are tighter than its top so it nests against the floating + * nav bar below (whose fused top corner is the same 14dp). They round back to [CollapsedCorner] as + * the bar is swiped away (detaching from the nav bar), and flatten to 0 as the card fills the screen. + */ +private val CollapsedBottomCorner: Dp = 14.dp /** Max dim of the app behind the surface, at full expansion. */ private const val ScrimMaxAlpha = 0.4f @@ -192,7 +199,17 @@ fun ExpandingNowPlayer( // whole card when the collapsed mini bar is swiped away to dismiss. .graphicsLayer { val f = state.expansion() - shape = RoundedCornerShape(lerp(CollapsedCorner, 0.dp, f)) + // Bottom corners nest against the floating nav bar at rest (14dp), round back to the top + // radius as the mini bar is swiped away, then flatten with the top corners as it opens. + val dismiss = (abs(dismissOffset.value) / (screenWidthPx * 0.4f)).coerceIn(0f, 1f) + val topC = lerp(CollapsedCorner, 0.dp, f) + val bottomC = lerp(lerp(CollapsedBottomCorner, CollapsedCorner, dismiss), 0.dp, f) + shape = RoundedCornerShape( + topStart = topC, + topEnd = topC, + bottomStart = bottomC, + bottomEnd = bottomC + ) clip = true shadowElevation = lerp(shadowPx, 0f, f) translationX = dismissOffset.value From 5578c592c38b090032fcbd01956f7afb3404670b Mon Sep 17 00:00:00 2001 From: Luis Vervaet Date: Wed, 15 Jul 2026 00:15:26 -0400 Subject: [PATCH 2/2] cool bounce animation --- .../ui/nowplaying/ExpandingNowPlayer.kt | 13 ++- .../ui/nowplaying/ExpandingPlayerState.kt | 108 +++++++++++++++--- .../ui/nowplaying/components/MediaControls.kt | 23 +++- 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt index dfd0973..f22d351 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingNowPlayer.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -111,6 +112,7 @@ fun ExpandingNowPlayer( val thresholdPx = with(density) { 5.dp.toPx() } remember(collapsedY, thresholdPx) { state.dragThresholdPx = thresholdPx + state.overDragPx = miniBarPx * 0.2f state.setCollapsedBounds(collapsedY) collapsedY } @@ -164,7 +166,8 @@ fun ExpandingNowPlayer( PredictiveBackHandler(enabled = isOpen && onNowPlayingRoute) { progress: Flow -> try { progress.collect { event -> state.onPredictiveBack(event.progress) } - state.collapse() + // The back gesture is drag-like, so finish with the momentum spring (bounce), not a glide. + state.collapseWithMomentum() } catch (_: CancellationException) { state.expand() } @@ -213,6 +216,14 @@ fun ExpandingNowPlayer( clip = true shadowElevation = lerp(shadowPx, 0f, f) translationX = dismissOffset.value + // Momentum overshoot: render the part of the settle/drag position that goes PAST the + // [0, collapsedY] end-stops as a real vertical offset, so a fast collapse physically dips + // past the mini-bar rest and springs back while geometry stays saturated at the end. + val ty = state.translationY.value + translationY = ty - ty.coerceIn(0f, state.collapsedY) + // Small cosmetic squash accent on collapse, anchored to the bottom edge. + scaleY = state.squashScaleY.value + transformOrigin = TransformOrigin(0.5f, 1f) } // INNER: content laid out at full height, bottom-aligned, revealed by the clip (not stretched). .layout { measurable, constraints -> diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingPlayerState.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingPlayerState.kt index 674a49a..0227f33 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingPlayerState.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/ExpandingPlayerState.kt @@ -1,8 +1,10 @@ package com.larvey.azuracastplayer.ui.nowplaying import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -19,18 +21,32 @@ import kotlin.math.abs * [collapsedY] = collapsed. A drag moves it 1:1 with the finger; releasing settles it to one end. * [expansion] (0..1) is *derived* from it so the two can never disagree — geometry, the layer * cross-fade, and the scrim all read it. + * + * When a settle spring overshoots an end-stop (or a drag pulls past it), [translationY] leaves the + * `[0, collapsedY]` range. [expansion] stays clamped so geometry saturates at the ends; the surface + * separately renders that out-of-range excess as a real vertical offset, so the momentum bounce is + * physically visible (see ExpandingNowPlayer's card `graphicsLayer`). */ class ExpandingPlayerState( private val scope: CoroutineScope ) { val translationY = Animatable(0f) + /** + * Cosmetic vertical squash on collapse (1f = none), riding on top of the real momentum overshoot. + * Velocity-independent — its depth is set only by how open the sheet was at release. + */ + val squashScaleY = Animatable(1f) + var collapsedY: Float = 0f private set /** Set once by the layout so drag thresholds are in real px. */ var dragThresholdPx: Float = 24f + /** How far (px) a drag may pull past either end-stop before hard-stopping (elastic over-drag). */ + var overDragPx: Float = 0f + private var initialized = false private var isDragging = false private var accumulated = 0f @@ -65,10 +81,15 @@ class ExpandingPlayerState( scope.launch(start = CoroutineStart.UNDISPATCHED) { translationY.stop() } } - /** [dy] = raw vertical drag delta in px (down = positive). Clamped to [0, collapsedY] (no overshoot). */ + /** + * [dy] = raw vertical drag delta in px (down = positive). Tracks the finger 1:1, allowing a small + * elastic over-drag ([overDragPx]) past either end-stop before it hard-stops. + */ fun onDrag(dy: Float) { accumulated += dy - val newY = (translationY.value + dy).coerceIn(0f, collapsedY) + // Hard stop at the fully-expanded end (0): once open, swiping up must NOT lift the player off the + // top and reveal content beneath it. The elastic over-drag stays only past the collapsed end. + val newY = (translationY.value + dy).coerceIn(0f, collapsedY + overDragPx) scope.launch(start = CoroutineStart.UNDISPATCHED) { translationY.snapTo(newY) } } @@ -81,7 +102,7 @@ class ExpandingPlayerState( abs(velocityY) > VELOCITY_THRESHOLD -> velocityY < 0f else -> expansion() > 0.5f } - settle(target, velocityY) + settle(target, velocityY, fromDrag = true) } /** Predictive-back preview: [progress] 0..1 slides the sheet from expanded toward collapsed. */ @@ -90,9 +111,18 @@ class ExpandingPlayerState( scope.launch(start = CoroutineStart.UNDISPATCHED) { translationY.snapTo(target) } } - fun expand() = settle(expand = true, velocityY = 0f) + fun expand() = settle(expand = true, velocityY = 0f, fromDrag = false) + + fun collapse() = settle(expand = false, velocityY = 0f, fromDrag = false) - fun collapse() = settle(expand = false, velocityY = 0f) + /** + * Collapse with the momentum spring (overshoot + bounce), as a drag release would. The back + * gesture routes here — it's a drag-like action, so it should physically bounce, not glide like the + * close button. [velocityY] px/s can be forwarded if the gesture carries one (0 = pure spring + * overshoot from the release point). + */ + fun collapseWithMomentum(velocityY: Float = 0f) = + settle(expand = false, velocityY = velocityY, fromDrag = true) /** Collapse to the mini bar and suspend until it finishes (used to sequence the animated stop). */ suspend fun animateCollapse() { @@ -110,27 +140,77 @@ class ExpandingPlayerState( /** Instantly reset to collapsed — called when playback ends so the next song opens as the mini bar. */ fun snapToCollapsed() { expandedState = false - scope.launch(start = CoroutineStart.UNDISPATCHED) { translationY.snapTo(collapsedY) } + scope.launch(start = CoroutineStart.UNDISPATCHED) { + translationY.snapTo(collapsedY) + squashScaleY.snapTo(1f) + } } - private fun settle(expand: Boolean, velocityY: Float) { + /** + * Settles the sheet to one end. + * + * Opening always glides on a fixed tween — the fling velocity is intentionally not carried, so a + * flick up simply glides open. A **drag-released collapse** instead pours the actual fling velocity + * into an underdamped, low-stiffness spring, so the sheet overshoots the mini-bar rest and springs + * back: the bounce IS the gesture's momentum, not a scripted effect. Its damping softens (bounces + * more) the more open the sheet was at release. A programmatic collapse (back / close button) has + * no momentum, so it glides like opening. + */ + private fun settle(expand: Boolean, velocityY: Float, fromDrag: Boolean) { expandedState = expand isDragging = false + val fractionBefore = expansion() val target = if (expand) 0f else collapsedY scope.launch { - translationY.animateTo( - targetValue = target, - initialVelocity = velocityY, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow + if (!expand && fromDrag) { + translationY.animateTo( + targetValue = target, + initialVelocity = velocityY, + animationSpec = spring( + dampingRatio = lerp( + Spring.DampingRatioNoBouncy, + COLLAPSE_DAMPING_OPEN, + fractionBefore + ), + stiffness = Spring.StiffnessLow + ) ) - ) + } else { + translationY.animateTo( + targetValue = target, + animationSpec = tween(durationMillis = 255, easing = FastOutSlowInEasing) + ) + } + } + // Cosmetic squash accent layered on the collapse (always from rest, velocity-independent). + scope.launch { + if (expand) { + squashScaleY.snapTo(1f) + } else { + squashScaleY.snapTo(lerp(1f, COLLAPSE_SQUASH, fractionBefore)) + squashScaleY.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessVeryLow + ) + ) + } } } companion object { private const val VELOCITY_THRESHOLD = 150f + + /** Deepest squash at the start of a collapse-from-fully-open (1f = none). */ + private const val COLLAPSE_SQUASH = 0.97f + + /** + * Spring damping when collapsing from fully open (lerps up to critically-damped 1.0 as the + * release point nears the mini bar). Higher = LESS overshoot travel, WITHOUT speeding the motion + * up — stiffness sets speed, damping sets throw. PixelPlayer uses 0.75; nudged up for less throw. + */ + private const val COLLAPSE_DAMPING_OPEN = 0.80f } } diff --git a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/MediaControls.kt b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/MediaControls.kt index 1f11162..4ecd5fc 100644 --- a/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/MediaControls.kt +++ b/app/src/main/java/com/larvey/azuracastplayer/ui/nowplaying/components/MediaControls.kt @@ -86,7 +86,8 @@ fun MediaControls( ) { val context = LocalContext.current val scheduler = AndroidAlarmScheduler(context) - val sleepTimer: MutableState = remember { mutableStateOf(SleepItem(LocalDateTime.now())) } + val sleepTimer: MutableState = + remember { mutableStateOf(SleepItem(LocalDateTime.now())) } val haptics = LocalHapticFeedback.current var showTimePicker by remember { mutableStateOf(false) } @@ -118,9 +119,22 @@ fun MediaControls( else -> 0.78f } } - val stopWeight by animateFloatAsState(weightFor(ControlSlot.STOP), AppMotion.spatialFast(), label = "stopWeight") - val playWeight by animateFloatAsState(weightFor(ControlSlot.PLAY_PAUSE), AppMotion.spatialFast(), label = "playWeight") - val sleepWeight by animateFloatAsState(weightFor(ControlSlot.SLEEP), AppMotion.spatialFast(), label = "sleepWeight") + + val stopWeight by animateFloatAsState( + weightFor(ControlSlot.STOP), + AppMotion.spatialFast(), + label = "stopWeight" + ) + val playWeight by animateFloatAsState( + weightFor(ControlSlot.PLAY_PAUSE), + AppMotion.spatialFast(), + label = "playWeight" + ) + val sleepWeight by animateFloatAsState( + weightFor(ControlSlot.SLEEP), + AppMotion.spatialFast(), + label = "sleepWeight" + ) // paused → full pill (half of the 80dp height), playing → squircle val playCorner by animateDpAsState( @@ -135,6 +149,7 @@ fun MediaControls( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) ) { // Stop ControlPill(