mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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
|
||||
* 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. */
|
||||
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 {
|
||||
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
|
||||
* 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<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 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() }),
|
||||
)
|
||||
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<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. */
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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…")
|
||||
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<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(
|
||||
"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 <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. */
|
||||
@Composable
|
||||
private fun Group(title: String) {
|
||||
|
||||
@@ -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<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 -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -267,13 +267,16 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
||||
@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(
|
||||
|
||||
@@ -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<ArtColor.Tint?>(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<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,
|
||||
),
|
||||
)
|
||||
}
|
||||
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<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) }
|
||||
}
|
||||
@@ -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<String, Pair<Int, Int>>()
|
||||
|
||||
private fun entryOf(item: MediaBrowser.MediaItem, parentStyle: Pair<Int, Int>?): 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
|
||||
Reference in new issue
Block a user