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