From ef1b2e1be7ddf9f2ece4ab838acb40be8934f0a5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 26 Oct 2026 00:34:55 -0700 Subject: [PATCH] home: drag the player card's edge to resize it, and the cover fills whatever the controls leave The width is saved. The controls sit along the bottom of the card. --- .../gearslip/car/CarHomeScreen.kt | 91 +++++++++++++++---- .../seb3thehacker/gearslip/car/CarSettings.kt | 11 +++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index fd6aa6b..66c510b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.R import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.mutableLongStateOf @@ -105,11 +106,19 @@ fun CarHome() { // The map runs edge to edge underneath; the player floats over one side of it as a card, with // a gap all round. The map's surface keeps its size when the card comes and goes, so the app // only re-centres its map instead of drawing onto a new surface. + val savedWidth by CarSettings.playerWidth.collectAsStateWithLifecycle() + // The width while the grip is being dragged; null otherwise. + var dragging by remember { androidx.compose.runtime.mutableStateOf(null) } BoxWithConstraints(Modifier.fillMaxSize()) { - val column = (maxWidth / PLAYER_SHARE).coerceAtMost(PLAYER_MAX_WIDTH) + val widest = maxWidth * PLAYER_MAX_SHARE + val settled = (if (savedWidth > 0f) savedWidth.dp else (maxWidth / PLAYER_SHARE).coerceAtMost(PLAYER_DEFAULT_MAX)) + .coerceIn(PLAYER_MIN_WIDTH, widest) + val column = dragging ?: settled androidx.compose.runtime.CompositionLocalProvider( LocalCardsAtEnd provides (splitScreen && playerLeft), - LocalSidePanelInset provides if (splitScreen) column else 0.dp, + // The settled width, not the one under the finger: the map app re-centres once, when + // the drag ends, rather than on every frame of it. + LocalSidePanelInset provides if (splitScreen) settled else 0.dp, ) { MapPane(navStatus, frame) } @@ -130,16 +139,63 @@ fun CarHome() { top = CARD_GAP, bottom = LocalNavBarInset.current + CARD_GAP, ) - if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card) + Box(card) { + val fill = Modifier.fillMaxSize() + if (shown.value == SidePanel.LYRICS) SideLyrics(now, fill) else DockedPlayer(now, fill) + ResizeGrip( + onDrag = { delta -> + val grown = if (playerLeft) delta else -delta + dragging = ((dragging ?: settled) + grown).coerceIn(PLAYER_MIN_WIDTH, widest) + }, + onDone = { + dragging?.let { CarSettings.setPlayerWidth(it.value) } + dragging = null + }, + modifier = Modifier.align(if (playerLeft) Alignment.CenterEnd else Alignment.CenterStart), + ) + } } } } -/** The player takes one part in this many of the screen's width, up to [PLAYER_MAX_WIDTH]. */ +/** Until it's dragged, the player takes one part in this many of the screen's width, up to [PLAYER_DEFAULT_MAX]. */ private const val PLAYER_SHARE = 3.6f -/** On a wide screen the card stops growing, so the map gets the extra room. */ -private val PLAYER_MAX_WIDTH = 340.dp +/** On a wide screen the default card stops growing, so the map gets the extra room. */ +private val PLAYER_DEFAULT_MAX = 340.dp + +/** How far the grip can take the card: narrow enough for the controls, wide enough to leave a map. */ +private val PLAYER_MIN_WIDTH = 240.dp +private const val PLAYER_MAX_SHARE = 0.5f + +/** + * The handle along the card's edge facing the map: a short bar to show it's there, in a strip wide + * enough for a finger. [onDrag] gets each move in dp, positive to the right. + */ +@Composable +private fun ResizeGrip(onDrag: (androidx.compose.ui.unit.Dp) -> Unit, onDone: () -> Unit, modifier: Modifier) { + val density = androidx.compose.ui.platform.LocalDensity.current + val state = androidx.compose.foundation.gestures.rememberDraggableState { px -> onDrag(with(density) { px.toDp() }) } + Box( + // Only the middle of the edge, so it stays clear of the close button in the corner. + modifier + .height(140.dp) + .width(28.dp) + .draggable( + state, + androidx.compose.foundation.gestures.Orientation.Horizontal, + onDragStopped = { onDone() }, + ), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = 5.dp, height = 44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)), + ) + } +} /** Between the player card and the screen's edges, and the map's own cards beside it. */ private val CARD_GAP = 12.dp @@ -199,8 +255,8 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame } /** - * The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat - * and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show + * The player, docked beside the map: the art as large as it fits, then title, progress, transport, + * and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show * twice. The X sends it back to the nav bar. */ @Composable @@ -214,17 +270,16 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { // The theme's surface rather than the art's colour: a slab of album colour beside the map // fought it for attention, and the art says the same thing in less space. Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) { - BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) { - // Whatever height the controls below leave over, so the art grows on a taller - // screen and shrinks rather than pushing the buttons off a short one. - val artSize = minOf(maxWidth - 44.dp, maxHeight - 290.dp).coerceIn(56.dp, 200.dp) + Box(Modifier.fillMaxSize().padding(12.dp)) { Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.fillMaxWidth()) { + // The cover gets everything the controls leave, as big a square as fits, sitting + // right on top of the title; the controls stay pinned to the bottom. + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) { + val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp) // Art opens the full media screen, same as on the pill. val coverShape = RoundedCornerShape(16.dp) Box( Modifier - .align(Alignment.TopCenter) .size(artSize) .gsPanelShadow(coverShape) .clip(coverShape) @@ -236,10 +291,6 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } } } - GsIconButton( - Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, - Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp, - ) } Spacer(Modifier.height(10.dp)) Text( @@ -264,6 +315,10 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { ) } } + GsIconButton( + Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, + Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp, + ) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index c4ca6e2..60e3bee 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -64,6 +64,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_PLAYER_TITLE = "car_player_title" private const val KEY_PLAYER_LEFT = "car_player_left" + private const val KEY_PLAYER_WIDTH = "car_player_width" private const val KEY_MORE_LYRICS = "car_more_lyrics" private const val KEY_THEME = "car_app_theme" private const val KEY_UI_THEME = "car_ui_theme" @@ -105,6 +106,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" private val autoplayFlow = MutableStateFlow(false) private val playerTitleFlow = MutableStateFlow(false) private val playerLeftFlow = MutableStateFlow(false) + private val playerWidthFlow = MutableStateFlow(0f) private val moreLyricsFlow = MutableStateFlow(true) private val themeFlow = MutableStateFlow(AppTheme.PHONE) val appTheme: StateFlow = themeFlow @@ -226,6 +228,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" /** The home screen's player sits left of the map instead of right of it. */ val playerLeft: StateFlow = playerLeftFlow + /** The docked player's width in dp, as last dragged; 0 until it has been, for the default. */ + val playerWidth: StateFlow = playerWidthFlow + /** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */ val moreLyrics: StateFlow = moreLyricsFlow @@ -321,6 +326,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true" playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true" + playerWidthFlow.value = AppSettings.getString(app, KEY_PLAYER_WIDTH, "0").toFloatOrNull() ?: 0f moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" // Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's @@ -399,6 +405,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString()) } + fun setPlayerWidth(dp: Float) { + playerWidthFlow.value = dp + AppSettings.putString(app, KEY_PLAYER_WIDTH, dp.toString()) + } + fun setMoreLyrics(on: Boolean) { moreLyricsFlow.value = on AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())