Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
}
}
}

}
}

Expand All @@ -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 }
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,7 +66,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
Expand Down Expand Up @@ -104,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
}
Expand Down Expand Up @@ -157,7 +166,8 @@ fun ExpandingNowPlayer(
PredictiveBackHandler(enabled = isOpen && onNowPlayingRoute) { progress: Flow<BackEventCompat> ->
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()
}
Expand Down Expand Up @@ -192,10 +202,28 @@ 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
// 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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) }
}

Expand All @@ -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. */
Expand All @@ -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() {
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ fun MediaControls(
) {
val context = LocalContext.current
val scheduler = AndroidAlarmScheduler(context)
val sleepTimer: MutableState<SleepItem> = remember { mutableStateOf(SleepItem(LocalDateTime.now())) }
val sleepTimer: MutableState<SleepItem> =
remember { mutableStateOf(SleepItem(LocalDateTime.now())) }
val haptics = LocalHapticFeedback.current

var showTimePicker by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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(
Expand All @@ -135,6 +149,7 @@ fun MediaControls(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
// Stop
ControlPill(
Expand Down
Loading