feat(android): swipe-up-to-expand gesture on MiniPlayer (#34)

Ports player_bar.dart's onVerticalDragEnd — an upward flick anywhere on
the bar expands into NowPlaying, alongside the existing tap-to-expand. A
vertical draggable claims only vertical-dominant drags, so the inline
seek slider keeps its horizontal scrub gesture. The 200 px/s Flutter
threshold is expressed in dp and density-converted so the flick feels
the same across screen densities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 13:33:43 -04:00
parent fbd6264037
commit 1ef5cd5b7a
@@ -4,6 +4,9 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -27,6 +30,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -52,6 +56,11 @@ private const val MINI_BAR_TONAL_ELEVATION_DP = 4
private const val COVER_SIZE_DP = 48
private const val SCRUBBER_ROW_HEIGHT_DP = 4
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and
// converted via density so the gesture feels the same across screens.
private const val SWIPE_UP_VELOCITY_DP = 200
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun MiniCover(coverUrl: String, contentDescription: String) {
@@ -126,10 +135,22 @@ fun MiniPlayer(
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
// Swipe up anywhere on the bar to expand into the full player —
// mirrors player_bar.dart. We only act on a clear upward flick
// (negative velocity past the threshold) so a slow tap-with-jitter
// doesn't accidentally open the screen. The horizontal seek slider
// keeps its own gestures; a vertical draggable only claims
// vertical-dominant drags.
val flickThresholdPx = with(LocalDensity.current) { SWIPE_UP_VELOCITY_DP.dp.toPx() }
Surface(
modifier = modifier
.fillMaxWidth()
.height(MINI_BAR_HEIGHT_DP.dp),
.height(MINI_BAR_HEIGHT_DP.dp)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { },
onDragStopped = { velocity -> if (velocity < -flickThresholdPx) onExpandClick() },
),
color = MaterialTheme.colorScheme.surface,
tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp,
) {