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) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
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
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.LocalReduceMotion
import com.rada.ui.theme.barEnter
import com.rada.ui.theme.verticalDragToDismiss
import com.rada.ui.theme.barExit
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -36,6 +37,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -62,7 +65,12 @@ fun NowPlayingBar(
val isLocal by viewModel.isLocal.collectAsState()
val streamMeta by viewModel.streamMeta.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).
val title: String
@ -93,6 +101,10 @@ fun NowPlayingBar(
Column(
modifier = Modifier
.fillMaxWidth()
.verticalDragToDismiss(
thresholdFraction = 0.4f,
onDismiss = { dismissedFor = sourceKey },
)
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(SatsAmpGlass.rCard))
// glass: textSafe 90% overlay + 6% white + highlight border

View file

@ -1,5 +1,6 @@
package com.rada.ui.theme
import androidx.compose.ui.unit.dp
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@ -8,6 +9,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
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.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
@ -142,3 +147,35 @@ fun Modifier.pressDip(
)
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)
}
},
)
}
}