diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index 4919429..292057f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -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>(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(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 { - // 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 46a135d..3902684 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -189,7 +189,20 @@ internal fun rememberAllEntries(): List { } /** 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) private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { entry.builtIn?.let { @@ -218,23 +231,29 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { } /** - * The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a - * group with Settings last of all, then with the driver's own moves from [custom] laid over it. - * Moved apps only trade places among themselves, so an app installed later still lands in its group. + * The launcher's tiles under their headings: grouped by [AppGroup], alphabetical inside a group + * with Settings last of all, then with the driver's own moves from [custom] laid over it. Moved + * 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, showVehicleData: Boolean, custom: List): List { +internal fun launcherSections(all: List, showVehicleData: Boolean, custom: List): List { val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData } val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() - val grouped = shown.sortedWith( + val sorted = shown.sortedWith( compareBy({ 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 } fun Entry.rank() = componentId?.let { rank[it] } - val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator() - return grouped.map { if (it.rank() != null) moved.next() else it } + return sorted.groupBy { groupOf(it, mediaPackages).heading }.map { (heading, entries) -> + 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, showVehicleData: Boolean, custom: List): List = + 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. */ internal fun findEntry(id: String): Entry? = 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 tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf) + val sections = launcherSections(all, showVehicleData, appOrder) LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 108.dp), + columns = GridCells.Adaptive(minSize = 116.dp), modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(14.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( onClick = tile.onClick, onLongClick = tile.onLongClick, - modifier = Modifier.height(120.dp), - colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), + modifier = Modifier.height(120.dp).gsPanelShadow(shape), + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = shape, ) { Box(Modifier.fillMaxSize()) { @@ -338,7 +368,7 @@ private fun AppTile(tile: Tile) { BrokenBadge( 22.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 private fun TileIcon(tile: Tile) { when { - tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp)) - tile.glyph != null -> Icon(tile.glyph, 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(56.dp)) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index b4ce854..510aa52 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -81,253 +84,264 @@ fun CarSettingsScreen() { value = withContext(Dispatchers.Default) { CertSummary.read(context) } } - LazyColumn( - Modifier.fillMaxSize(), - // 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), - ) { - item { - ScreenHeader("Settings") - } + // A rail of the groups down the left, and the chosen group's settings beside it: five short + // lists instead of one long scroll. + var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) } + val listState = androidx.compose.foundation.lazy.rememberLazyListState() + androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) } + 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 } + LazyColumn( + 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") } - - item { - ChoiceRow( - "Open on connect", - listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, - 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(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…") + if (section == chosen) item { + ChoiceRow( + "Open on connect", + listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, + open, + ) { CarSettings.setOpenOnConnect(it) } } - } - 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()) + if (section == chosen) item { + ChoiceRow( + "Size", + listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"), + 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(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( - "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) + 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) } + } + + 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 ChoiceRow(title: String, options: List>, selecte } } +/** The groups, with the chosen one lit: a card down the left of Settings. */ +@Composable +private fun SettingsRail(groups: List, 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. */ @Composable private fun Group(title: String) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt index 1723375..38560ad 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import android.provider.Settings import android.content.Intent import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column 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.entries.isEmpty() -> Notice("Nothing here.", null) - else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) } + else -> BrowseList(browse.entries, 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, 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) + +private fun browseSections(entries: List): List { + val out = mutableListOf() + var run = mutableListOf() + 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 ----------------------------------------------------------------------------------- /** @@ -148,15 +248,15 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) { Surface( onClick = { media.playQueueItem(track) }, shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { RowArt(icon, MediaIcons.MusicNote) 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()) { - 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) @@ -173,15 +273,15 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = Surface( onClick = onClick, shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote) 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()) { - 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) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index b6aac31..ea66bdd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -100,8 +101,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { val art = rememberArt(now) - // The art's colour behind everything, header included; the buttons keep their own. - Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) { + // The theme's background, with the cover's colours glowing faintly behind the cover on the left. + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true) if (phase != CarMedia.Phase.READY) { Column(Modifier.fillMaxSize()) { 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)) Box(Modifier.weight(1f).fillMaxWidth()) { 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.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) @@ -603,11 +605,11 @@ internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier @Composable private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) { BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { + val shape = RoundedCornerShape(22.dp) Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - shadowElevation = 6.dp, - modifier = Modifier.size(minOf(maxWidth, maxHeight)), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape), ) { if (art != null) { 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, * the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index 0a50ca5..8a5b293 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -267,13 +267,16 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) @Composable private fun MoveButtons(id: String) { 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) if (index < 0) return fun move(by: Int) { val next = order.toMutableList() 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)) { GsButton( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt index 36915b6..d0c7ad7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt @@ -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 - * capped amount of its saturation come from the art; the lightness stays close to the theme's - * own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same - * on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than - * jumping. + * 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. [wide] is for + * a whole screen: the pools gather on its left, behind the cover, and reach less far across it. */ @Composable -internal fun rememberArtColor( - art: android.graphics.Bitmap?, - fallback: Color, - darkLightness: Float = 0.2f, - lightLightness: Float = 0.86f, -): Color { - val dark = fallback.luminance() < 0.5f - val tint by produceState(null, art) { - value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } } +internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) { + val spots by produceState>(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(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, + ), + ) + } + 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) - } ?: fallback - return animateColorAsState(target, tween(600), label = "art colour").value +} + +/** The cover's colours at its top left, top right and middle. */ +private fun glowColours(art: android.graphics.Bitmap): List { + // 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) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt index dd0d9a2..325c0d7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -37,6 +37,10 @@ class MediaEntry( val iconBitmap: Bitmap?, val browsable: 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. */ @@ -271,7 +275,10 @@ class CarMedia(private val context: Context) { GearslipLog.w("media: ${app.label} went away") _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 GearslipLog.i("media: connecting to ${app.label}'s library") runCatching { mb.connect() }.onFailure { @@ -342,6 +349,8 @@ class CarMedia(private val context: Context) { GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") attach(MediaController(context, mb.sessionToken)) rootId = mb.root + childStyles.clear() + mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) } path.clear() _phase.value = Phase.READY open(mb.root) @@ -427,7 +436,7 @@ class CarMedia(private val context: Context) { if (parentId != subscribed) return _browse.value = BrowseState( trail = path.map { it.second }, - entries = children.map(::entryOf), + entries = children.map { entryOf(it, childStyles[parentId]) }, loading = false, // An empty top level is an app keeping its library to itself, not an empty one. 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>() + + private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair?): MediaEntry { 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( id = item.mediaId.orEmpty(), title = d.title?.toString().orEmpty(), @@ -451,6 +473,8 @@ class CarMedia(private val context: Context) { iconBitmap = d.iconBitmap, browsable = item.isBrowsable, 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 } } + +/** 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