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 androidx.compose.ui.input.pointer.util.VelocityTracker
import kotlin.math.hypot import kotlin.math.hypot
import androidx.compose.foundation.layout.Box 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -73,10 +75,28 @@ fun CarAppStage(
if (status.surfaceRequested) { if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize()) 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( TemplateChrome(
template, 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)) BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
} }
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -64,9 +66,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* The home screen: the map, with the docked player beside it when asked for. With the player on * The home screen: the map, with the docked player floating over one side of it when asked for.
* the left, the map app's cards move to the map's far edge, so the player, the map and the cards * With the player on the left, the map app's cards move to the map's far edge, so the player, the
* each keep their own space. * 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 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 * 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 splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle() 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. // The map runs edge to edge underneath; the player floats over one side of it as a card, with
Row(Modifier.fillMaxSize()) { // a gap all round. The map's surface keeps its size when the card comes and goes, so the app
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current) // only re-centres its map instead of drawing onto a new surface.
if (splitScreen && playerLeft) { BoxWithConstraints(Modifier.fillMaxSize()) {
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) val column = maxWidth / PLAYER_SHARE
} androidx.compose.runtime.CompositionLocalProvider(
Box( LocalCardsAtEnd provides (splitScreen && playerLeft),
Modifier LocalSidePanelInset provides if (splitScreen) column else 0.dp,
.weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight(),
) { ) {
androidx.compose.runtime.CompositionLocalProvider(LocalCardsAtEnd provides (splitScreen && playerLeft)) { MapPane(navStatus, frame)
MapPane(navStatus, frame)
}
} }
if (splitScreen && !playerLeft) { androidx.compose.animation.AnimatedVisibility(
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) 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 @Composable
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) { private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
@@ -127,7 +153,10 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
} }
Column( 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, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, 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 // 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. // 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)) { BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
// Whatever height the controls below leave over, so the art grows on a taller // 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. // 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) { Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
// Art opens the full media screen, same as on the pill. // Art opens the full media screen, same as on the pill.
val coverShape = RoundedCornerShape(16.dp)
Box( Box(
Modifier Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.size(artSize) .size(artSize)
.clip(RoundedCornerShape(12.dp)) .gsPanelShadow(coverShape)
.clip(coverShape)
.background(scheme.surface) .background(scheme.surface)
.clickable { navigator.media() }, .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( GsIconButton(
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, 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 context = LocalContext.current
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } 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)) { Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) { 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.withTintedSurfaces
import app.seb3thehacker.gearslip.car.theme.GsIconBox import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsIconButton 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.GsButton
import app.seb3thehacker.gearslip.car.theme.GsTone import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors import app.seb3thehacker.gearslip.car.theme.gsColors
@@ -164,7 +165,8 @@ internal fun CarNavBar(navigator: CarNavigator) {
val frame by CarEnvironment.frame.collectAsState() val frame by CarEnvironment.frame.collectAsState()
val running = rememberRunningApps() 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)) { BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the // 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 } 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. */ /** 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 cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END 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 * outlines: cheap, works on every API level, and already soft enough that the car's video
* compression has nothing crisp to smear. * 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) { private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
if (alpha <= 0f) return if (alpha <= 0f) return
val blur = elevation * 2f val blur = elevation * 2f
@@ -64,6 +64,12 @@ data class ControlLook(
* from; a flat look takes the dot. * from; a flat look takes the dot.
*/ */
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL, 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. */ /** Replaces this look when the UI is light, for effects that only work on one background. */
val light: ControlLook? = null, val light: ControlLook? = null,
) )
@@ -72,7 +78,11 @@ object GsThemes {
val Keycap = GsTheme( val Keycap = GsTheme(
id = "keycap", id = "keycap",
name = "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( val Tactile = GsTheme(
@@ -89,6 +99,8 @@ object GsThemes {
pressedDim = 0.12f, pressedDim = 0.12f,
pressedScale = 1f, pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL, 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 // 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. // background, so light mode gets a tight one and leans on the bevel instead.
light = ControlLook( light = ControlLook(
@@ -102,6 +114,8 @@ object GsThemes {
pressedDim = 0.12f, pressedDim = 0.12f,
pressedScale = 1f, pressedScale = 1f,
holdMarker = HoldMarkerStyle.PEEL, holdMarker = HoldMarkerStyle.PEEL,
panelShadow = 4.dp,
panelShadowAlpha = 0.22f,
), ),
), ),
) )