mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user