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.
This commit is contained in:
Seb3thehacker committed 2026-10-26 00:22:28 -07:00
1 parent a1a0546b74
commit 8a2dae4173
6 files changed
+134 -26

No files matched your search

@@ -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))
@@ -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)) {
@@ -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
@@ -160,6 +160,13 @@ internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { 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
@@ -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
@@ -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,
),
),
)