From 8a2279d5dff9913d7ca3a85524f809493f464442 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 19:50:55 -0400 Subject: [PATCH] feat(android): NowPlaying drag-down-to-dismiss + close button Audit v2 missing UX: full player had no way to escape except system back. Two additions: - Chevron-down close button as the Scaffold topBar navigation icon. Transparent background so the gradient/cover behind shows through. - Vertical drag-down on the whole screen pops back past a 200px threshold, mirroring Flutter's modal-page gesture. Horizontal scrubs on the slider still work because the gesture detector is specifically vertical-only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/player/ui/NowPlayingScreen.kt | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index ccd1331d..1385d33b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -17,8 +17,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -38,6 +44,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import coil3.compose.AsyncImage +import com.composables.icons.lucide.ChevronDown import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music @@ -64,6 +71,12 @@ private const val MS_PER_SECOND = 1000L private const val SECONDS_PER_MINUTE = 60L private const val POP_GRACE_MS = 500L +// Vertical drag-down threshold (in pixels) past which the gesture +// pops the player. Matches Flutter's 80px threshold in spirit; +// using pointer-input pixels not dp because gesture velocity also +// participates in the dismiss decision. +private const val DRAG_DISMISS_THRESHOLD_PX = 200f + /** * Full-screen player. Shows the cover (placeholder until AlbumRef join * lands), title + artist + album, a scrubber, and a transport row @@ -99,7 +112,8 @@ fun NowPlayingScreen( return } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController), + topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = MaterialTheme.colorScheme.background, ) { inner -> @@ -113,6 +127,50 @@ fun NowPlayingScreen( } } +/** + * Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a + * downward drag past the threshold pops the screen, returning the + * user to whichever shell route launched NowPlaying. Tap, swipe-up, + * and horizontal drags pass through to the underlying content + * (slider scrub, etc.). + */ +@Composable +private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier = + this.pointerInput(Unit) { + var totalDragY = 0f + detectVerticalDragGestures( + onDragStart = { totalDragY = 0f }, + onDragEnd = { + if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) { + navController.popBackStack() + } + totalDragY = 0f + }, + onDragCancel = { totalDragY = 0f }, + onVerticalDrag = { _, delta -> totalDragY += delta }, + ) + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NowPlayingTopBar(onClose: () -> Unit) { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Lucide.ChevronDown, + contentDescription = "Close player", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) +} + @Composable private fun NowPlayingBody( inner: androidx.compose.foundation.layout.PaddingValues,