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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:50:55 -04:00
parent 29c676fcf8
commit 8a2279d5df
@@ -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,