From 8a2dae417323f35d1eaba5b29f7bd2e6b1be0e59 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 26 Oct 2026 00:22:28 -0700 Subject: [PATCH] home: the player floats over the map as a card, with theme shadows on the card, cover and nav bar The map keeps its full size underneath, so docking the player only re-centres it. Tactile and Keycap cast shadows, Flat stays flat. The card slides in and a new cover fades in. --- .../seb3thehacker/gearslip/car/CarAppStage.kt | 22 ++++- .../gearslip/car/CarHomeScreen.kt | 80 +++++++++++++------ .../seb3thehacker/gearslip/car/CarNavBar.kt | 4 +- .../gearslip/car/TemplateChrome.kt | 7 ++ .../gearslip/car/theme/GsControls.kt | 31 +++++++ .../gearslip/car/theme/GsTheme.kt | 16 +++- 6 files changed, 134 insertions(+), 26 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index 33df319..64c7953 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.util.VelocityTracker import kotlin.math.hypot import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -73,10 +75,28 @@ fun CarAppStage( if (status.surfaceRequested) { AppSurface(connection, frame, template, Modifier.fillMaxSize()) } + // The docked player floats over one side of the map: the chrome keeps clear of it, + // and the app hears that side is covered, so its map centres in the rest. + val panel = LocalSidePanelInset.current + val panelAtStart = LocalCardsAtEnd.current TemplateChrome( template, - Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar), + Modifier.fillMaxSize().padding( + top = if (embedded) 0.dp else CHROME_BAR, + bottom = navBar, + start = if (panelAtStart) panel else 0.dp, + end = if (panelAtStart) 0.dp else panel, + ), ) + if (panel > 0.dp) { + Box( + Modifier + .align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd) + .fillMaxHeight() + .width(panel) + .covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END), + ) + } BottomCover(navBar, Modifier.align(Alignment.BottomCenter)) } if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) 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 c5accd3..75b2cca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import app.seb3thehacker.gearslip.car.theme.gsPanelShadow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PlayArrow @@ -64,9 +66,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying import kotlinx.coroutines.launch /** - * The home screen: the map, with the docked player beside it when asked for. With the player on - * the left, the map app's cards move to the map's far edge, so the player, the map and the cards - * each keep their own space. + * The home screen: the map, with the docked player floating over one side of it when asked for. + * With the player on the left, the map app's cards move to the map's far edge, so the player, the + * map and the cards each keep their own space. * * The map is whatever navigation app was used last, brought back on its own. The player sits in * the nav bar's pill until its dock button moves it here; the column closes itself back to a @@ -96,28 +98,52 @@ fun CarHome() { } val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle() + // What the card showed last, so it still has something to show while it slides away. + val shown = remember { androidx.compose.runtime.mutableStateOf(SidePanel.CONTROLS) } + if (panel != SidePanel.NONE) shown.value = panel - // No padding anywhere, split screen or not: the player sits flush against the map. - Row(Modifier.fillMaxSize()) { - val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current) - if (splitScreen && playerLeft) { - if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) - } - Box( - Modifier - .weight(if (splitScreen) 2.1f else 1f) - .fillMaxHeight(), + // 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. + BoxWithConstraints(Modifier.fillMaxSize()) { + val column = maxWidth / PLAYER_SHARE + androidx.compose.runtime.CompositionLocalProvider( + LocalCardsAtEnd provides (splitScreen && playerLeft), + LocalSidePanelInset provides if (splitScreen) column else 0.dp, ) { - androidx.compose.runtime.CompositionLocalProvider(LocalCardsAtEnd provides (splitScreen && playerLeft)) { - MapPane(navStatus, frame) - } + MapPane(navStatus, frame) } - if (splitScreen && !playerLeft) { - if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) + androidx.compose.animation.AnimatedVisibility( + splitScreen, + Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd), + enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } + + androidx.compose.animation.fadeIn(tween(180)), + exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } + + androidx.compose.animation.fadeOut(tween(140)), + ) { + val card = Modifier + .width(column) + .fillMaxHeight() + .padding( + start = if (playerLeft) CARD_GAP else 0.dp, + end = if (playerLeft) 0.dp else CARD_GAP, + top = CARD_GAP, + bottom = LocalNavBarInset.current + CARD_GAP, + ) + if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card) } } } +/** The player takes one part in this many of the screen's width, as it did beside the map. */ +private const val PLAYER_SHARE = 3.1f + +/** Between the player card and the screen's edges, and the map's own cards beside it. */ +private val CARD_GAP = 12.dp + +/** The card's corners: rounder than a button's, as the map app's own cards are. */ +private val CARD_SHAPE = RoundedCornerShape(24.dp) + @Composable private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) { val navigator = LocalCarNavigator.current @@ -127,7 +153,10 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame } Column( - Modifier.fillMaxSize().padding(24.dp), + Modifier.fillMaxSize().padding( + start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp, + end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current, + ).padding(24.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -181,7 +210,7 @@ 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, color = scheme.surfaceContainer) { + 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. @@ -189,15 +218,20 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.fillMaxWidth()) { // Art opens the full media screen, same as on the pill. + val coverShape = RoundedCornerShape(16.dp) Box( Modifier .align(Alignment.TopCenter) .size(artSize) - .clip(RoundedCornerShape(12.dp)) + .gsPanelShadow(coverShape) + .clip(coverShape) .background(scheme.surface) .clickable { navigator.media() }, ) { - art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + // A new song's cover fades in over the old one instead of cutting to it. + androidx.compose.animation.Crossfade(art, animationSpec = tween(350), label = "cover") { shown -> + shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + } } GsIconButton( Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, @@ -242,7 +276,7 @@ private fun SideLyrics(now: NowPlaying, modifier: Modifier) { val context = LocalContext.current val scheme = MaterialTheme.colorScheme val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } - Surface(modifier = modifier, color = scheme.surfaceContainer) { + Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) { Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt index d602a69..efb093e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt @@ -78,6 +78,7 @@ import app.seb3thehacker.gearslip.car.theme.GsColors import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconButton +import app.seb3thehacker.gearslip.car.theme.gsBarShadow import app.seb3thehacker.gearslip.car.theme.GsButton import app.seb3thehacker.gearslip.car.theme.GsTone import app.seb3thehacker.gearslip.car.theme.gsColors @@ -164,7 +165,8 @@ internal fun CarNavBar(navigator: CarNavigator) { val frame by CarEnvironment.frame.collectAsState() val running = rememberRunningApps() - Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + // Casts the theme's panel shadow up onto the map, so the bar sits above it rather than beside it. + Surface(Modifier.gsBarShadow(), color = MaterialTheme.colorScheme.surfaceContainer) { BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { // Home and Apps are two different places now, not a toggle on one button - the diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt index 4745844..5f81673 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -160,6 +160,13 @@ internal val LocalChromeCover = compositionLocalOf { null } */ internal val LocalCardsAtEnd = compositionLocalOf { false } +/** + * How much of the map's width the docked player floats over, on the side opposite the cards. The + * map itself runs underneath at full size; the app is told this part is covered, and the chrome + * keeps clear of it. + */ +internal val LocalSidePanelInset = compositionLocalOf { 0.dp } + /** The edge the app's cards cover, and the one its map buttons do. */ internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt index 61c97ec..0fe3ba5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt @@ -172,6 +172,37 @@ fun Modifier.gsSurface( * outlines: cheap, works on every API level, and already soft enough that the car's video * compression has nothing crisp to smear. */ +/** + * The current theme's shadow under a panel floating over the map, such as the docked player. + * Draws nothing in a look without one. + */ +@Composable +fun Modifier.gsPanelShadow(shape: Shape): Modifier { + val look = gsLook() + if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this + return drawBehind { drawSoftShadow(shape, look.panelShadow.toPx(), look.panelShadowAlpha, 1f) } +} + +/** The same shadow cast upwards from the top edge of a bar along the bottom of the screen. */ +@Composable +fun Modifier.gsBarShadow(): Modifier { + val look = gsLook() + if (look.panelShadow <= 0.dp || look.panelShadowAlpha <= 0f) return this + return drawBehind { + val reach = look.panelShadow.toPx() * 1.5f + drawRect( + Brush.verticalGradient( + 0f to Color.Transparent, + 1f to Color.Black.copy(alpha = look.panelShadowAlpha * 0.6f), + startY = -reach, + endY = 0f, + ), + topLeft = androidx.compose.ui.geometry.Offset(0f, -reach), + size = Size(size.width, reach), + ) + } +} + private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) { if (alpha <= 0f) return val blur = elevation * 2f diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt index 2f284fe..db8be63 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt @@ -64,6 +64,12 @@ data class ControlLook( * from; a flat look takes the dot. */ val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL, + /** + * The shadow a panel floating over the map casts onto it (the docked player, the lyrics), and + * the nav bar casts up. 0 for none, so a flat look stays flat. + */ + val panelShadow: Dp = 0.dp, + val panelShadowAlpha: Float = 0f, /** Replaces this look when the UI is light, for effects that only work on one background. */ val light: ControlLook? = null, ) @@ -72,7 +78,11 @@ object GsThemes { val Keycap = GsTheme( id = "keycap", name = "Keycap", - control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL), + // Short and dark, like the keys' own solid base. + control = ControlLook( + lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL, + panelShadow = 4.dp, panelShadowAlpha = 0.45f, + ), ) val Tactile = GsTheme( @@ -89,6 +99,8 @@ object GsThemes { pressedDim = 0.12f, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL, + panelShadow = 12.dp, + panelShadowAlpha = 0.42f, // A wide soft shadow bands once the car's video stream compresses it on a light // background, so light mode gets a tight one and leans on the bevel instead. light = ControlLook( @@ -102,6 +114,8 @@ object GsThemes { pressedDim = 0.12f, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL, + panelShadow = 4.dp, + panelShadowAlpha = 0.22f, ), ), )