car: a faint album-colour glow on the player card, screens fade in as they open, and the phone app follows the Dark mode setting

This commit is contained in:
Seb3thehacker committed 2026-10-26 00:59:39 -07:00
1 parent 08eef32e59
commit 608d72b856
4 files changed
+81 -16

No files matched your search

@@ -212,9 +212,10 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
// 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.
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
// slab of album colour beside the map fought it for attention; this only hints at the song.
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
AlbumGlow(art, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().padding(12.dp)) {
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
// The cover gets everything the controls leave, as big a square as fits, sitting
@@ -268,6 +269,46 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
}
}
/**
* Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
* shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
* stream nothing between songs. Fainter on a light card, where colour shows more.
*/
@Composable
private fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier) {
val spots by produceState<List<Color>>(emptyList(), art) {
value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
}
val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
val fade = tween<Color>(600)
val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val third by androidx.compose.animation.animateColorAsState(spots.getOrNull(2)?.copy(alpha = strength * 0.8f) ?: Color.Transparent, fade, label = "glow")
androidx.compose.foundation.Canvas(modifier) {
fun pool(colour: Color, x: Float, y: Float, reach: Float) {
if (colour.alpha <= 0f) return
drawRect(
androidx.compose.ui.graphics.Brush.radialGradient(
listOf(colour, Color.Transparent),
center = androidx.compose.ui.geometry.Offset(size.width * x, size.height * y),
radius = size.width * reach,
),
)
}
pool(third, 0.5f, 0.5f, 0.85f)
pool(first, 0.2f, 0.1f, 0.9f)
pool(second, 0.85f, 0.22f, 0.8f)
}
}
/** The cover's colours at its top left, top right and middle. */
private fun glowColours(art: android.graphics.Bitmap): List<Color> {
// A hardware bitmap can't be scaled or read on the CPU, so copy it out first.
val readable = if (art.config == android.graphics.Bitmap.Config.HARDWARE) art.copy(android.graphics.Bitmap.Config.ARGB_8888, false) else art
val small = android.graphics.Bitmap.createScaledBitmap(readable ?: return emptyList(), 3, 3, true)
return listOf(small.getPixel(0, 0), small.getPixel(2, 0), small.getPixel(1, 1)).map { Color(it).copy(alpha = 1f) }
}
/**
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
@@ -62,6 +62,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.animation.animateContentSize
import android.media.session.PlaybackState
import androidx.compose.runtime.remember
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -275,30 +277,46 @@ fun CarUi() {
// Every other screen, and everything that pops up, in a layer over Home rather
// than inside its area: full screen grows this layer over the nav bar's row while
// Home, and the map's surface in it, keeps its size.
// Each new screen fades in while rising a little, once, over a fifth of a second.
// Coming from Home the backdrop fades in with it, over the map; between two other
// screens it stays put, so the map never flashes through.
val lastScreen = remember { arrayOf<CarScreen>(CarScreen.Home) }
val fromHome = remember(screen) { lastScreen[0] == CarScreen.Home }
SideEffect { lastScreen[0] = screen }
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
if (safetyWarningSeen) {
Box(Modifier.fillMaxSize()) {
if (screen != CarScreen.Home) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer { if (fromHome) alpha = enter.value }
.background(MaterialTheme.colorScheme.background)
// The bar never hides here, so keep the screen clear of it.
.padding(bottom = if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } },
) {
when (screen) {
CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Browse -> BrowseAppScreen(navigator)
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
Box(
Modifier.fillMaxSize().graphicsLayer {
if (!fromHome) alpha = enter.value
translationY = (1f - enter.value) * 16.dp.toPx()
},
) {
when (screen) {
CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Browse -> BrowseAppScreen(navigator)
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
}
}
}
@@ -60,6 +60,8 @@ private fun ColorScheme.withDarkStyle(style: DarkStyle): ColorScheme {
}
}
return copy(
// Grey text that read fine on grey looks dim on black, so it moves a little towards white.
onSurfaceVariant = lerp(onSurfaceVariant, onSurface, 0.3f),
background = at(background, 0),
surface = at(surface, 0),
surfaceDim = at(surfaceDim, 0),
@@ -5,17 +5,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
/**
* Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists),
* with the same accent tint over the surfaces as the car screen, so the two look like one app.
* with the same surfaces as the car screen, so the two look like one app.
*/
@Composable
fun GearslipTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val dark = isSystemInDarkTheme()
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark)
// The car's Dark mode setting, so the phone and the car screen stay one look.
val darkStyle by app.seb3thehacker.gearslip.car.CarSettings.darkStyle.collectAsState()
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark, darkStyle)
MaterialTheme(colorScheme = scheme, content = content)
}