car: media app gets a big-cover Now playing and Browse with covers and sections, Apps get headings, Settings a rail of groups

This commit is contained in:
Seb3thehacker committed 2026-10-26 01:14:00 -07:00
1 parent 608d72b856
commit 1f9ef552c1
8 files changed
+595 -342

No files matched your search

@@ -269,46 +269,6 @@ 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 * 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 * player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
@@ -189,7 +189,20 @@ internal fun rememberAllEntries(): List<Entry> {
} }
/** The launcher's groups, in the order they're laid out. */ /** The launcher's groups, in the order they're laid out. */
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT } private enum class AppGroup { MAPS, MUSIC, PHONE, MESSAGING, OTHER, WEB, SCREEN_SHARING, SETTINGS, EXIT }
/** The heading a group's tiles sit under; calls and messages share one, as do Gearslip's own. */
private val AppGroup.heading: String
get() = when (this) {
AppGroup.MAPS -> "Navigation"
AppGroup.MUSIC -> "Media"
AppGroup.PHONE, AppGroup.MESSAGING -> "Calls and messages"
AppGroup.OTHER -> "Other apps"
else -> "Gearslip"
}
/** One heading on the launcher and the tiles under it, in order. */
internal class LauncherSection(val heading: String, val entries: List<Entry>)
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup { private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
entry.builtIn?.let { entry.builtIn?.let {
@@ -218,23 +231,29 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
} }
/** /**
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a * The launcher's tiles under their headings: grouped by [AppGroup], alphabetical inside a group
* group with Settings last of all, then with the driver's own moves from [custom] laid over it. * with Settings last of all, then with the driver's own moves from [custom] laid over it. Moved
* Moved apps only trade places among themselves, so an app installed later still lands in its group. * apps only trade places among themselves and inside their own heading, so an app installed later
* still lands in its group and nothing ends up under the wrong heading.
*/ */
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> { internal fun launcherSections(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<LauncherSection> {
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData } val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
val grouped = shown.sortedWith( val sorted = shown.sortedWith(
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }), compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
) )
if (custom.isEmpty()) return grouped
val rank = custom.withIndex().associate { (i, id) -> id to i } val rank = custom.withIndex().associate { (i, id) -> id to i }
fun Entry.rank() = componentId?.let { rank[it] } fun Entry.rank() = componentId?.let { rank[it] }
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator() return sorted.groupBy { groupOf(it, mediaPackages).heading }.map { (heading, entries) ->
return grouped.map { if (it.rank() != null) moved.next() else it } val moved = entries.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
LauncherSection(heading, entries.map { if (it.rank() != null) moved.next() else it })
}
} }
/** The launcher's tiles in the order they're drawn, headings aside. */
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> =
launcherSections(all, showVehicleData, custom).flatMap { it.entries }
/** An app by the id a pin or the running list knows it by, built-in or installed. */ /** An app by the id a pin or the running list knows it by, built-in or installed. */
internal fun findEntry(id: String): Entry? = internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id } BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
@@ -287,16 +306,27 @@ fun CarLauncher() {
} }
val appOrder by CarSettings.appOrder.collectAsState() val appOrder by CarSettings.appOrder.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf) val sections = launcherSections(all, showVehicleData, appOrder)
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), columns = GridCells.Adaptive(minSize = 116.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
items(tiles) { AppTile(it) } sections.forEach { section ->
item(span = { GridItemSpan(maxLineSpan) }, contentType = "heading") {
Text(
section.heading,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, top = 12.dp),
)
}
items(section.entries.map(::tileOf)) { AppTile(it) }
}
} }
} }
@@ -320,8 +350,8 @@ private fun AppTile(tile: Tile) {
GsIconBox( GsIconBox(
onClick = tile.onClick, onClick = tile.onClick,
onLongClick = tile.onLongClick, onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp), modifier = Modifier.height(120.dp).gsPanelShadow(shape),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = shape, shape = shape,
) { ) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -338,7 +368,7 @@ private fun AppTile(tile: Tile) {
BrokenBadge( BrokenBadge(
22.dp, 22.dp,
Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp) Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape), .border(2.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
) )
} }
} }
@@ -352,8 +382,8 @@ private fun AppTile(tile: Tile) {
@Composable @Composable
private fun TileIcon(tile: Tile) { private fun TileIcon(tile: Tile) {
when { when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp)) tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(56.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp)) tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(56.dp))
} }
} }
@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -81,253 +84,264 @@ fun CarSettingsScreen() {
value = withContext(Dispatchers.Default) { CertSummary.read(context) } value = withContext(Dispatchers.Default) { CertSummary.read(context) }
} }
LazyColumn( // A rail of the groups down the left, and the chosen group's settings beside it: five short
Modifier.fillMaxSize(), // lists instead of one long scroll.
// Built as it scrolls into view rather than all at once: every row here is a set of var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
// buttons, and building the whole screen made opening Settings visibly slow. val listState = androidx.compose.foundation.lazy.rememberLazyListState()
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
verticalArrangement = Arrangement.spacedBy(20.dp), Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) {
) { SettingsRail(listOf("Screen", "Nav bar and player", "Sound and voice", "Warnings", "More"), chosen) { chosen = it }
item { LazyColumn(
ScreenHeader("Settings") Modifier.weight(1f).fillMaxHeight(),
} state = listState,
// Built as it scrolls into view rather than all at once: every row here is a set of
// buttons, and building the whole screen made opening Settings visibly slow.
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
var section: Int
section = 0
if (section == chosen) item { Group("Screen") }
item { Group("Screen") } if (section == chosen) item {
ChoiceRow(
item { "Open on connect",
ChoiceRow( listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
"Open on connect", open,
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, ) { CarSettings.setOpenOnConnect(it) }
open,
) { CarSettings.setOpenOnConnect(it) }
}
item {
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
}
item {
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
}
if (theme != AppTheme.LIGHT) item {
ChoiceRow(
"Dark mode",
listOf(
DarkStyle.BLACK to "Black",
DarkStyle.BLACK_COLOUR to "Black and colour",
DarkStyle.DEEP_COLOUR to "Deep colour",
DarkStyle.GREY to "Grey",
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
item {
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
item {
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
}
item {
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
item {
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (screenSize != null) item {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
item { Group("Nav bar and player") }
item {
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
item {
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
item {
ChoiceRow(
"Player beside the map",
listOf(false to "Right", true to "Left"),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
item {
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
}
item {
ChoiceRow(
"Lyrics from",
listOf(false to "LRCLIB", true to "More sources"),
moreLyrics,
) { CarSettings.setMoreLyrics(it) }
}
item { Group("Sound and voice") }
item {
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item {
Text(
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item { Group("Warnings") }
if (!pipeAudio) item {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
item {
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
item {
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
item {
Text(
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
"speaks. Turn it off if your map app's own voice already plays in the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
"none, so Gearslip keeps using its own.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
item { Group("More") }
item {
WebPageSetting()
}
item {
ScreenFit(vehicle)
}
item {
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo)
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
} }
}
item { if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ChoiceRow(
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) "Size",
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) scale,
) { CarSettings.setScale(it) }
}
if (section == chosen) item {
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
}
if (section == chosen && theme != AppTheme.LIGHT) item {
ChoiceRow(
"Dark mode",
listOf(
DarkStyle.BLACK to "Black",
DarkStyle.BLACK_COLOUR to "Black and colour",
DarkStyle.DEEP_COLOUR to "Deep colour",
DarkStyle.GREY to "Grey",
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
if (section == chosen) item {
ChoiceRow(
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
if (section == chosen) item {
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
}
if (section == chosen) item {
ChoiceRow(
"Units",
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
if (section == chosen) item {
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (section == chosen && screenSize != null) item {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
section = 1
if (section == chosen) item { Group("Nav bar and player") }
if (section == chosen) item {
ChoiceRow(
"Nav bar",
listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
if (section == chosen) item {
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
if (section == chosen) item {
ChoiceRow(
"Player beside the map",
listOf(false to "Right", true to "Left"),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
if (section == chosen) item {
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
}
if (section == chosen) item {
ChoiceRow(
"Lyrics from",
listOf(false to "LRCLIB", true to "More sources"),
moreLyrics,
) { CarSettings.setMoreLyrics(it) }
}
section = 2
if (section == chosen) item { Group("Sound and voice") }
if (section == chosen) item {
ChoiceRow(
"Car audio",
listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
if (section == chosen) item {
Text(
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
section = 3
if (section == chosen) item { Group("Warnings") }
if (section == chosen && !pipeAudio) item {
ChoiceRow(
"Warn when Bluetooth isn't connected",
listOf(false to "Off", true to "On"),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
"Warn when location is off",
listOf(false to "Off", true to "On"),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
"Spoken directions",
listOf(false to "Off", true to "On"),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
if (section == chosen) item {
Text(
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
"speaks. Turn it off if your map app's own voice already plays in the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen) item {
ExperimentalFeatureRow( ExperimentalFeatureRow(
"What's new after updates", title = "Use the phone's speech recognition",
"Show the list of changes once, the first time the car connects after an update.", description = "Listens through the phone's own speech service instead of Gearslip's. On " +
whatsNew, "most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
) { CarSettings.setWhatsNewAfterUpdates(it) } "none, so Gearslip keeps using its own.",
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) { checked = systemSpeech,
Text("What's new", style = MaterialTheme.typography.titleMedium) ) { CarSettings.setSystemSpeech(it) }
}
section = 4
if (section == chosen) item { Group("More") }
if (section == chosen) item {
WebPageSetting()
}
if (section == chosen) item {
ScreenFit(vehicle)
}
if (section == chosen) item {
ChoiceRow(
"Experimental features",
listOf(false to "Hide", true to "Show"),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (section == chosen && experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo)
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
"What's new after updates",
"Show the list of changes once, the first time the car connects after an update.",
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text("What's new", style = MaterialTheme.typography.titleMedium)
}
} }
} }
} }
@@ -352,6 +366,47 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
} }
} }
/** The groups, with the chosen one lit: a card down the left of Settings. */
@Composable
private fun SettingsRail(groups: List<String>, chosen: Int, onChoose: (Int) -> Unit) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(24.dp)
Surface(
Modifier.width(250.dp).fillMaxHeight().gsPanelShadow(shape),
shape = shape,
color = scheme.surfaceContainer,
) {
Column(
Modifier.padding(10.dp).verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Settings",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp),
)
groups.forEachIndexed { i, name ->
val on = i == chosen
GsIconBox(
onClick = { onChoose(i) },
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
else GsColors(Color.Transparent, scheme.onSurface),
latched = on,
) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}
}
}
}
}
/** A group of settings: a heading, so a long list reads as a few short ones. */ /** A group of settings: a heading, so a long list reads as a few short ones. */
@Composable @Composable
private fun Group(title: String) { private fun Group(title: String) {
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings import android.provider.Settings
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -104,13 +105,112 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m
) )
browse.failed -> Notice("Could not load this list.", null) browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null) browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { else -> BrowseList(browse.entries, media)
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) } }
}
}
/**
* A list laid out the way the app asked: under its section headings, covers where it asked for a
* grid and rows everywhere else. A grid section among others is a shelf that scrolls sideways; a
* list that is one grid and nothing else wraps into rows of covers instead.
*/
@Composable
private fun BrowseList(entries: List<MediaEntry>, media: CarMedia) {
val sections = remember(entries) { browseSections(entries) }
BoxWithConstraints(Modifier.fillMaxSize()) {
val onlyGrid = sections.size == 1 && sections[0].grid && sections[0].heading == null
val columns = ((maxWidth + SHELF_GAP) / (SHELF_COVER + SHELF_GAP)).toInt().coerceAtLeast(2)
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
sections.forEachIndexed { i, section ->
val heading = section.heading
if (heading != null && sections.getOrNull(i - 1)?.heading != heading) {
item(key = "heading $i") {
Text(
heading,
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = if (i == 0) 0.dp else 8.dp),
)
}
}
when {
!section.grid -> items(section.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
onlyGrid -> items(section.entries.chunked(columns)) { row ->
Row(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
row.forEach { CoverTile(it, media, Modifier.weight(1f)) }
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
else -> item(key = "shelf $i") {
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(SHELF_GAP)) {
items(section.entries, key = { it.id }) { CoverTile(it, media, Modifier.width(SHELF_COVER)) }
}
}
}
} }
} }
} }
} }
private val SHELF_COVER = 132.dp
private val SHELF_GAP = 14.dp
/** A run of entries under one heading that are all covers or all rows. */
private class BrowseSection(val heading: String?, val grid: Boolean, val entries: List<MediaEntry>)
private fun browseSections(entries: List<MediaEntry>): List<BrowseSection> {
val out = mutableListOf<BrowseSection>()
var run = mutableListOf<MediaEntry>()
for (entry in entries) {
val last = run.lastOrNull()
if (last != null && (last.group != entry.group || last.grid != entry.grid)) {
out += BrowseSection(last.group, last.grid, run)
run = mutableListOf()
}
run += entry
}
run.lastOrNull()?.let { out += BrowseSection(it.group, it.grid, run) }
return out
}
/** One entry as a cover with its name under it: what the app's grids are made of. */
@Composable
private fun CoverTile(entry: MediaEntry, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
val shape = RoundedCornerShape(16.dp)
Column(modifier.clip(shape).clickable { media.select(entry) }) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.gsPanelShadow(shape)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
?: Icon(
if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f),
)
}
Text(
entry.title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 8.dp, start = 2.dp),
)
if (entry.subtitle.isNotEmpty()) {
Text(
entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 2.dp, bottom = 4.dp),
)
}
}
}
// --- up next ----------------------------------------------------------------------------------- // --- up next -----------------------------------------------------------------------------------
/** /**
@@ -148,15 +248,15 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
Surface( Surface(
onClick = { media.playQueueItem(track) }, onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, MediaIcons.MusicNote) RowArt(icon, MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) { if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -173,15 +273,15 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote) RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) { if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(entry.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
if (entry.browsable && entry.playable) { if (entry.browsable && entry.playable) {
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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 androidx.compose.foundation.layout.width
@@ -100,8 +101,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val art = rememberArt(now) val art = rememberArt(now)
// The art's colour behind everything, header included; the buttons keep their own. // The theme's background, with the cover's colours glowing faintly behind the cover on the left.
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
if (phase != CarMedia.Phase.READY) { if (phase != CarMedia.Phase.READY) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
@@ -197,7 +199,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) { when (tab) {
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize()) Tab.NOW_PLAYING -> StageControls(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
@@ -603,11 +605,11 @@ internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier
@Composable @Composable
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) { private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val shape = RoundedCornerShape(22.dp)
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shadowElevation = 6.dp, modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
) { ) {
if (art != null) { if (art != null) {
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
@@ -621,6 +623,54 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
} }
} }
/**
* Now playing beside the big cover: the title in large type, then the seek bar and every control,
* all settled at the bottom. On a wide screen the transport and like, shuffle and repeat share
* one row; narrower, they take a row each.
*/
@Composable
private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
BoxWithConstraints(modifier) {
val oneRow = maxWidth >= 820.dp
Column(
// At least as tall as the space, so the controls sit at its bottom; scrolls if they don't fit.
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).heightIn(min = maxHeight).padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
) {
Column {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.title.isEmpty() && now.error == null) {
Text(
"Press play to pick up where you left off, or find something in Browse.",
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
PlayerProgress(now, media, Modifier.fillMaxWidth())
if (oneRow) {
Row(verticalAlignment = Alignment.CenterVertically) {
TransportRow(now, media, skip = 66.dp, modifier = Modifier.weight(1f))
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.weight(1f), maxOther = 2)
}
} else {
TransportRow(now, media, skip = 64.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = 54.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
}
}
}
/** /**
* Everything about the track and every control for it, read top to bottom: title and artist, * Everything about the track and every control for it, read top to bottom: title and artist,
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons. * the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
@@ -267,13 +267,16 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@Composable @Composable
private fun MoveButtons(id: String) { private fun MoveButtons(id: String) {
val custom by CarSettings.appOrder.collectAsState() val custom by CarSettings.appOrder.collectAsState()
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId } // Only within the app's own heading: past its edge it would land under another one.
val sections = launcherSections(rememberAllEntries(), rememberVehicleDataShown(), custom)
val section = sections.find { s -> s.entries.any { it.componentId == id } } ?: return
val order = section.entries.mapNotNull { it.componentId }
val index = order.indexOf(id) val index = order.indexOf(id)
if (index < 0) return if (index < 0) return
fun move(by: Int) { fun move(by: Int) {
val next = order.toMutableList() val next = order.toMutableList()
java.util.Collections.swap(next, index, index + by) java.util.Collections.swap(next, index, index + by)
CarSettings.setAppOrder(next) CarSettings.setAppOrder(sections.flatMap { s -> if (s === section) next else s.entries.mapNotNull { it.componentId } })
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsButton( GsButton(
@@ -229,26 +229,48 @@ internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
} }
/** /**
* A background taken from the album art's main colour, in place of [fallback]. Only the hue and a * Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
* capped amount of its saturation come from the art; the lightness stays close to the theme's * shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same * stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than * a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
* jumping.
*/ */
@Composable @Composable
internal fun rememberArtColor( internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
art: android.graphics.Bitmap?, val spots by produceState<List<Color>>(emptyList(), art) {
fallback: Color, value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
darkLightness: Float = 0.2f, }
lightLightness: Float = 0.86f, val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
): Color { val fade = tween<Color>(600)
val dark = fallback.luminance() < 0.5f val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
val tint by produceState<ArtColor.Tint?>(null, art) { val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } } 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,
),
)
}
if (wide) {
pool(third, 0.22f, 0.8f, 0.35f)
pool(first, 0.1f, 0.25f, 0.42f)
pool(second, 0.3f, 0.45f, 0.38f)
} else {
pool(third, 0.5f, 0.5f, 0.85f)
pool(first, 0.2f, 0.1f, 0.9f)
pool(second, 0.85f, 0.22f, 0.8f)
}
} }
val target = tint?.let { }
if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness)
else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness) /** The cover's colours at its top left, top right and middle. */
} ?: fallback private fun glowColours(art: android.graphics.Bitmap): List<Color> {
return animateColorAsState(target, tween(600), label = "art colour").value // 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) }
} }
@@ -37,6 +37,10 @@ class MediaEntry(
val iconBitmap: Bitmap?, val iconBitmap: Bitmap?,
val browsable: Boolean, val browsable: Boolean,
val playable: Boolean, val playable: Boolean,
/** Shown as a cover in a grid rather than a row, as the app asked. */
val grid: Boolean = false,
/** The heading the app files this under, when it splits a list into sections. */
val group: String? = null,
) )
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */ /** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
@@ -271,7 +275,10 @@ class CarMedia(private val context: Context) {
GearslipLog.w("media: ${app.label} went away") GearslipLog.w("media: ${app.label} went away")
_phase.value = Phase.REJECTED _phase.value = Phase.REJECTED
} }
}, null) }, Bundle().apply {
// Tells the app it may ask for grids and section headings, as it would of Android Auto.
putBoolean(STYLE_SUPPORTED, true)
})
browser = mb browser = mb
GearslipLog.i("media: connecting to ${app.label}'s library") GearslipLog.i("media: connecting to ${app.label}'s library")
runCatching { mb.connect() }.onFailure { runCatching { mb.connect() }.onFailure {
@@ -342,6 +349,8 @@ class CarMedia(private val context: Context) {
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
attach(MediaController(context, mb.sessionToken)) attach(MediaController(context, mb.sessionToken))
rootId = mb.root rootId = mb.root
childStyles.clear()
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
path.clear() path.clear()
_phase.value = Phase.READY _phase.value = Phase.READY
open(mb.root) open(mb.root)
@@ -427,7 +436,7 @@ class CarMedia(private val context: Context) {
if (parentId != subscribed) return if (parentId != subscribed) return
_browse.value = BrowseState( _browse.value = BrowseState(
trail = path.map { it.second }, trail = path.map { it.second },
entries = children.map(::entryOf), entries = children.map { entryOf(it, childStyles[parentId]) },
loading = false, loading = false,
// An empty top level is an app keeping its library to itself, not an empty one. // An empty top level is an app keeping its library to itself, not an empty one.
unavailable = path.isEmpty() && children.isEmpty(), unavailable = path.isEmpty() && children.isEmpty(),
@@ -441,8 +450,21 @@ class CarMedia(private val context: Context) {
}) })
} }
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry { /**
* How a folder asked for its children to be shown, browsable ones and playable ones: by
* folder id, read off the folder itself (or the root's extras) before it's opened.
*/
private val childStyles = HashMap<String, Pair<Int, Int>>()
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): MediaEntry {
val d = item.description val d = item.description
val extras = d.extras
if (extras != null && item.isBrowsable) {
childStyles[item.mediaId.orEmpty()] = extras.getInt(STYLE_BROWSABLE, 0) to extras.getInt(STYLE_PLAYABLE, 0)
}
// The item's own hint first, then what its folder asked for its kind of child.
val style = extras?.getInt(STYLE_SINGLE_ITEM, 0)?.takeIf { it != 0 }
?: (if (item.isBrowsable) parentStyle?.first else parentStyle?.second) ?: 0
return MediaEntry( return MediaEntry(
id = item.mediaId.orEmpty(), id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(), title = d.title?.toString().orEmpty(),
@@ -451,6 +473,8 @@ class CarMedia(private val context: Context) {
iconBitmap = d.iconBitmap, iconBitmap = d.iconBitmap,
browsable = item.isBrowsable, browsable = item.isBrowsable,
playable = item.isPlayable, playable = item.isPlayable,
grid = style == STYLE_GRID || style == STYLE_CATEGORY_GRID,
group = extras?.getString(STYLE_GROUP_TITLE)?.takeIf { it.isNotBlank() },
) )
} }
@@ -772,3 +796,12 @@ class CarMedia(private val context: Context) {
const val SEARCH_NOTICE_MS = 6_000L const val SEARCH_NOTICE_MS = 6_000L
} }
} }
/** Android Auto's browse layout hints, which any app built for it already sends. */
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
private const val STYLE_SINGLE_ITEM = "android.media.browse.CONTENT_STYLE_SINGLE_ITEM_HINT"
private const val STYLE_GROUP_TITLE = "android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"
private const val STYLE_GRID = 2
private const val STYLE_CATEGORY_GRID = 4