feat(ui): swipe-down to dismiss the mini-player

- verticalDragToDismiss: bar follows the finger downward
  (translationY, capped 120dp) and dismisses past 40% height or on a
  downward fling; springs back otherwise; static under Reduce Motion
- Dismissal is per-source: playback continues, and the bar returns
  automatically when a different station/song starts
- Tabs route now owns its mini-player (root no longer stacks one)
- On-device: swipe dismissed bar while PLAYING; station change
  re-showed it
This commit is contained in:
avi 2026-09-08 10:07:25 -05:00
commit 918aeaf8d3
3 changed files with 51 additions and 2 deletions

View file

@ -29,7 +29,7 @@ import com.rada.ui.streams.StreamsScreen
fun NavGraph(navController: NavHostController) { fun NavGraph(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
val showNowPlayingBar = currentRoute != Screen.Player.route val showNowPlayingBar = currentRoute != Screen.Player.route && currentRoute != Screen.Tabs.route
val reduce = LocalReduceMotion.current val reduce = LocalReduceMotion.current
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {

View file

@ -8,6 +8,7 @@ import com.rada.ui.theme.metadataEnter
import com.rada.ui.theme.metadataExit import com.rada.ui.theme.metadataExit
import com.rada.ui.theme.LocalReduceMotion import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.barEnter import com.rada.ui.theme.barEnter
import com.rada.ui.theme.verticalDragToDismiss
import com.rada.ui.theme.barExit import com.rada.ui.theme.barExit
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -36,6 +37,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -62,7 +65,12 @@ fun NowPlayingBar(
val isLocal by viewModel.isLocal.collectAsState() val isLocal by viewModel.isLocal.collectAsState()
val streamMeta by viewModel.streamMeta.collectAsState() val streamMeta by viewModel.streamMeta.collectAsState()
val localMedia by viewModel.localMedia.collectAsState() val localMedia by viewModel.localMedia.collectAsState()
val isVisible = stationName.isNotEmpty() // Swipe-down (or quick drag-down) dismisses the bar until a *new*
// source starts playing — a floating thing should answer to gestures.
var dismissedFor by remember { mutableStateOf<String?>(null) }
val sourceKey = stationName
if (dismissedFor != null && dismissedFor != sourceKey) dismissedFor = null
val isVisible = stationName.isNotEmpty() && dismissedFor != sourceKey
// Spotify-style two-line bar: track (or station) + artist (or station). // Spotify-style two-line bar: track (or station) + artist (or station).
val title: String val title: String
@ -93,6 +101,10 @@ fun NowPlayingBar(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalDragToDismiss(
thresholdFraction = 0.4f,
onDismiss = { dismissedFor = sourceKey },
)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.clip(RoundedCornerShape(SatsAmpGlass.rCard)) .clip(RoundedCornerShape(SatsAmpGlass.rCard))
// glass: textSafe 90% overlay + 6% white + highlight border // glass: textSafe 90% overlay + 6% white + highlight border

View file

@ -1,5 +1,6 @@
package com.rada.ui.theme package com.rada.ui.theme
import androidx.compose.ui.unit.dp
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
@ -8,6 +9,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.CubicBezierEasing
@ -142,3 +147,35 @@ fun Modifier.pressDip(
) )
this.graphicsLayer { scaleX = scale; scaleY = scale } this.graphicsLayer { scaleX = scale; scaleY = scale }
} }
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
// A floating bar should answer to a downward drag: it follows the finger
// (translationY only) and dismisses past a threshold or on a downward fling;
// otherwise it springs back. Reduce Motion: no follow, threshold-only dismiss.
fun Modifier.verticalDragToDismiss(
thresholdFraction: Float = 0.5f,
onDismiss: () -> Unit,
): Modifier = composed {
val reduce = LocalReduceMotion.current
var dragPx by remember { mutableFloatStateOf(0f) }
val density = androidx.compose.ui.platform.LocalDensity.current
val maxDrag = with(density) { 120.dp.toPx() }
this
.graphicsLayer { translationY = if (reduce) 0f else dragPx }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragPx > size.height * thresholdFraction) onDismiss()
dragPx = 0f
},
onDragCancel = { dragPx = 0f },
onVerticalDrag = { change, amount ->
if (amount > 0) {
change.consume()
dragPx = (dragPx + amount).coerceIn(0f, maxDrag)
}
},
)
}
}