diff --git a/.gitignore b/.gitignore
index 4079af0..9a36ebc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,10 +18,10 @@ local.properties
*.keystore
keystore.properties
-# Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh
archive/
CLAUDE.md
*.pdf
extraction/
-.claude/
\ No newline at end of file
+.claude/
+todo
\ No newline at end of file
diff --git a/NOTICE.md b/NOTICE.md
index f8914c7..aff3759 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -1,16 +1,9 @@
# License
-Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`).
+Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`.
-In short: you're free to use, run, and modify it. If you modify it — or run a modified
-version as a service others use over a network — the AGPL requires you to make your source
-changes available on the same terms. That obligation follows the code even when you don't
-redistribute a binary, which is what makes the AGPL fit for something that can run as a
-background service.
+You may use, run, and modify Gearslip freely. If you distribute a modified version, or let others use one over a network, you must publish your changes under the same license. The obligation follows the code even when you hand out no binary, which suits software that runs as a background service.
## Commercial licensing
-If the AGPL's terms don't work for you — for example, you want to use Gearslip privately
-without being required to publish your changes — a separate commercial license is available
-directly from the author. Terms (including any restrictions on modification) are set on a
-case-by-case basis. Contact me though Matrix (on my profile) to discuss one.
\ No newline at end of file
+If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile.
diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts
index f858ebc..40a5506 100644
--- a/gearslip/build.gradle.kts
+++ b/gearslip/build.gradle.kts
@@ -34,6 +34,7 @@ android {
targetSdk = 37
versionCode = 5
versionName = "0.0.05-alpha"
+ manifestPlaceholders["appLabel"] = "Gearslip"
}
signingConfigs {
@@ -53,6 +54,14 @@ android {
release {
signingConfig = signingConfigs.findByName("release")
}
+ // Its own package, so a dev build installs beside the release one instead of over it.
+ // Signed with the release key when it's here: both builds declare the car template
+ // permission, and Android refuses a second app declaring it under a different key.
+ debug {
+ applicationIdSuffix = ".dev"
+ signingConfigs.findByName("release")?.let { signingConfig = it }
+ manifestPlaceholders["appLabel"] = "Gearslip Dev"
+ }
}
buildFeatures {
@@ -108,6 +117,7 @@ dependencies {
// their Bundleables deserialize into.
implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected)
+ implementation(libs.androidx.media)
testImplementation(libs.junit)
}
diff --git a/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml b/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..ecb61a7
--- /dev/null
+++ b/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml
index 3b98919..077e32c 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -91,7 +91,7 @@
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
- android:label="Gearslip"
+ android:label="${appLabel}"
android:supportsRtl="true"
android:theme="@style/Theme.Gearslip">
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt
index 56c3eba..8c7a161 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt
@@ -1,10 +1,9 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.net.Uri
import android.provider.Settings
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -14,10 +13,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -26,10 +22,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.CornerRadius
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import app.seb3thehacker.gearslip.R
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -77,15 +72,13 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
}
}
if (state.missingPermission) {
- FilledTonalButton(onClick = {
+ GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Grant") }
}
- IconButton(onClick = CarAssistant::cancel) {
- Icon(Icons.Filled.Close, contentDescription = "Dismiss")
- }
+ GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
}
}
}
@@ -107,37 +100,8 @@ private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) {
// the glyph itself doesn't need to animate for this to read clearly on a glance.
}
-/**
- * A mic capsule on a small stand, drawn rather than a vector asset - the trimmed core icon set
- * this project ships (see [ChargingGlyph]'s doc comment) has no microphone.
- */
+/** The assistant's mic, from `R.drawable.mic_24`, tinted with the current content colour. */
@Composable
fun MicGlyph(modifier: Modifier = Modifier) {
- val tint = LocalContentColor.current
- Canvas(modifier) {
- val w = size.width
- val h = size.height
- val capsuleWidth = w * 0.42f
- val capsuleHeight = h * 0.58f
- drawRoundRect(
- color = tint,
- topLeft = Offset((w - capsuleWidth) / 2f, 0f),
- size = Size(capsuleWidth, capsuleHeight),
- cornerRadius = CornerRadius(capsuleWidth / 2f),
- )
- val standStroke = h * 0.09f
- val standTop = capsuleHeight * 0.85f
- val standBottom = h * 0.82f
- drawArc(
- color = tint,
- startAngle = 20f,
- sweepAngle = 140f,
- useCenter = false,
- topLeft = Offset(w * 0.14f, standTop - h * 0.06f),
- size = Size(w * 0.72f, h * 0.5f),
- style = Stroke(width = standStroke),
- )
- drawLine(tint, Offset(w / 2f, standBottom - h * 0.05f), Offset(w / 2f, standBottom), strokeWidth = standStroke)
- drawLine(tint, Offset(w * 0.30f, standBottom), Offset(w * 0.70f, standBottom), strokeWidth = standStroke)
- }
+ Icon(ImageVector.vectorResource(R.drawable.mic_24), contentDescription = "Assistant", modifier = modifier)
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt
index cb00795..e8da410 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.car
+import androidx.compose.foundation.shape.CircleShape
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,9 +14,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Close
-import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -98,12 +98,14 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
@Composable
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- FilledIconButton(
- onClick = onClick,
+ GsIconButton(
+ icon, label, onClick,
+ colors = GsColors(color, Color.White),
enabled = enabled,
- modifier = Modifier.size(72.dp),
- colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
- ) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
+ size = 72.dp,
+ iconSize = 30.dp,
+ shape = CircleShape,
+ )
Spacer(Modifier.height(8.dp))
Text(label, style = MaterialTheme.typography.labelLarge)
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt
index af4a949..a42c619 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.gestures.detectTapGestures
@@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -157,7 +157,7 @@ private fun ChromeBar(
}.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall)
}
- TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) }
+ GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt
index d34afec..1f398be 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt
@@ -22,7 +22,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState
-import androidx.compose.foundation.clickable
+import app.seb3thehacker.gearslip.car.theme.GsColors
+import app.seb3thehacker.gearslip.car.theme.GsIconBox
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -43,7 +44,8 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject
/**
- * Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both
+ * Opened from the nav bar clock: the current weather, the week's calendar and the phone's
+ * notifications, at a glance. The first two
* need permissions granted on the phone (READ_CALENDAR, location) - if either was declined
* this just shows an empty state instead of asking again from inside the car UI.
*/
@@ -61,6 +63,7 @@ fun CarDashboardScreen() {
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
WeatherCard(context)
AgendaCard(context)
+ NotificationsSection()
}
}
@@ -70,10 +73,10 @@ private fun WeatherCard(context: Context) {
val navigator = LocalCarNavigator.current
LaunchedEffect(Unit) { Weather.refresh(context) }
- Surface(
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ GsIconBox(
+ onClick = { navigator.weather() },
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.extraLarge,
- modifier = Modifier.clickable { navigator.weather() },
) {
Row(
Modifier.fillMaxWidth().padding(20.dp),
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
index 165923e..7191589 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,12 +14,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import android.support.v4.media.session.PlaybackStateCompat
+import app.seb3thehacker.gearslip.R
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import android.media.session.PlaybackState
+import app.seb3thehacker.gearslip.media.MediaArt
+import kotlinx.coroutines.delay
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import app.seb3thehacker.gearslip.media.ArtColor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -35,11 +64,11 @@ import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch
/**
- * The home screen: the map on the left, lyrics on the right when asked for.
+ * The home screen: the map on the left, the docked player on the right when asked for.
*
- * The map is whatever navigation app was used last, brought back on its own. Playback itself is
- * controlled from the nav bar's pill everywhere, including here - this column only opens when the
- * pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
+ * The map is whatever navigation app was used last, brought back on its own. The player sits in
+ * the nav bar's pill until its dock button moves it here; the column closes itself back to a
+ * full-width map once nothing is playing.
*/
@Composable
fun CarHome() {
@@ -52,10 +81,11 @@ fun CarHome() {
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val navigator = LocalCarNavigator.current
- val splitScreen = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
+ val panel = navigator.sidePanel
+ val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
// No outer padding on any side, split screen or not - only the gap between the two panes
- // when lyrics are sharing the screen with the map.
+ // when the player is sharing the screen with the map.
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -68,7 +98,8 @@ fun CarHome() {
MapPane(navStatus, frame)
}
if (splitScreen) {
- LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
+ val side = Modifier.weight(1f).fillMaxHeight()
+ if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
}
}
}
@@ -95,7 +126,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
- TextButton(onClick = navigator::apps) { Text("Choose another app") }
+ GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
}
else -> if (!hasNavApps) {
// Nothing to auto-select and nothing to pick from Car apps either - this is the
@@ -111,8 +142,8 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
// not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves.
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
- TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
- TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
+ GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
+ GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
} else {
// Autostart is still picking one - a navigation app is installed, so this resolves
// to a "Starting..." state itself, in the next frame or two.
@@ -122,32 +153,96 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
}
/**
- * Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
- * the pill itself now, not here - this column has exactly one job.
+ * The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
+ * and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
+ * twice. The X sends it back to the nav bar.
*/
@Composable
-private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
+private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current
- Surface(
- modifier = modifier,
- color = MaterialTheme.colorScheme.surfaceVariant,
- ) {
- Column(Modifier.fillMaxSize().padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ val media = CarServices.media
+ val context = LocalContext.current
+ val scheme = MaterialTheme.colorScheme
+ val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
+
+ Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
+ BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
+ // Whatever height the controls below leave over, so the art grows on a taller
+ // screen and shrinks rather than pushing the buttons off a short one.
+ val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
+ Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(Modifier.fillMaxWidth()) {
+ // Art opens the full media screen, same as on the pill.
+ Box(
+ Modifier
+ .align(Alignment.TopCenter)
+ .size(artSize)
+ .clip(RoundedCornerShape(12.dp))
+ .background(scheme.surface)
+ .clickable { navigator.media() },
+ ) {
+ art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
+ }
+ GsIconButton(
+ Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
+ Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
+ )
+ }
+ Spacer(Modifier.height(10.dp))
Text(
- now.title.ifEmpty { "Lyrics" },
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
+ now.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
+ maxLines = 1, overflow = TextOverflow.Ellipsis,
)
- IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
- Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
+ Text(
+ now.artist, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis,
+ )
+ if (now.durationMs > 0) {
+ PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
+ } else {
+ Spacer(Modifier.height(10.dp))
+ }
+ TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(10.dp))
+ PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
+ GsIconButton(
+ ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
+ size = 42.dp, iconSize = 22.dp,
+ )
}
}
- Spacer(Modifier.height(4.dp))
- LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
+ }
+ }
+}
+
+/**
+ * Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a
+ * larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
+ * button swaps these back out for the controls.
+ */
+@Composable
+private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
+ val navigator = LocalCarNavigator.current
+ val context = LocalContext.current
+ val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
+ Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.Top) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
+ maxLines = 2, overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ now.artist, style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis,
+ )
+ }
+ GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
+ }
+ Spacer(Modifier.height(12.dp))
+ LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt
index 3da3f0c..fc06773 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -25,7 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
/**
@@ -138,11 +138,11 @@ fun CarKeyboard(
@Composable
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
- Surface(
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurface,
+ GsIconBox(
+ onClick = onClick,
+ modifier = modifier.height(KEY_HEIGHT),
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
shape = RoundedCornerShape(8.dp),
- modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
@@ -161,11 +161,11 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
@Composable
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
- Surface(
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ GsIconBox(
+ onClick = onClick,
+ modifier = modifier.height(KEY_HEIGHT),
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
shape = RoundedCornerShape(8.dp),
- modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
@@ -179,11 +179,11 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
@Composable
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
- Surface(
- color = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
+ GsIconBox(
+ onClick = onClick,
+ modifier = modifier.height(KEY_HEIGHT),
+ colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(8.dp),
- modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
index 4a5877c..04125e4 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
@@ -1,9 +1,8 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.border
-import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -16,19 +15,20 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Call
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.produceState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
@@ -57,6 +57,8 @@ import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog
+import app.seb3thehacker.gearslip.notify.MessagingApp
+import app.seb3thehacker.gearslip.notify.MessagingCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -71,8 +73,6 @@ private class Tile(
val broken: Boolean = false,
/** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */
val partial: Boolean = false,
- val pinned: Boolean = false,
- /** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
val onLongClick: (() -> Unit)? = null,
val onClick: () -> Unit,
)
@@ -83,18 +83,34 @@ internal class Entry(
val icon: ImageBitmap?,
val template: TemplateApp? = null,
val media: MediaApp? = null,
+ val builtIn: BuiltInApp? = null,
+ val messaging: MessagingApp? = null,
) {
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
- val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
+ val componentId: String?
+ get() = builtIn?.let { BUILT_IN_PREFIX + it.id }
+ ?: messaging?.let { MESSAGING_PREFIX + it.packageName }
+ ?: (template?.component ?: media?.component)?.flattenToString()
+
+ val packageName: String?
+ get() = template?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName
}
+/** A messaging app has no component of its own to open, so its pin carries the package instead. */
+internal const val MESSAGING_PREFIX = "messages:"
+
/**
* The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* which is when new installs are picked up.
*/
internal object LauncherCache {
- @Volatile var entries: List? = null
+ /** Observed, not read once: the nav bar is drawn before the car session's rescan finishes,
+ * and an app installed since the last scan has to show up there when it does. */
+ private val flow = MutableStateFlow?>(null)
+ val state: StateFlow?> = flow.asStateFlow()
+
+ val entries: List? get() = flow.value
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@Synchronized
@@ -117,10 +133,29 @@ internal object LauncherCache {
Entry(label, iconOf(it.component.packageName), template = it)
}
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
- return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it }
+ // A messenger that is also a car media app keeps only its player tile.
+ val messaging = MessagingCatalog.installed(context)
+ .filter { it.packageName !in mediaPackages }
+ .map { Entry(it.label, iconOf(it.packageName), messaging = it) }
+ return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it }
}
}
+/** Every app the launcher knows: Gearslip's own and the installed ones, rescans included. */
+@Composable
+internal fun rememberAllEntries(): List {
+ val context = LocalContext.current
+ val installed by LauncherCache.state.collectAsState()
+ LaunchedEffect(context) {
+ if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
+ }
+ return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
+}
+
+/** 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 }
+
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
@@ -130,6 +165,7 @@ fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = tru
* kind of app.
*/
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
+ entry.builtIn?.let { it.open(navigator); return }
entry.template?.let {
if (it.isNavigation) {
CarServices.connectNav(it, frame)
@@ -140,48 +176,38 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
}
}
entry.media?.let { CarServices.openMedia(it); navigator.media() }
+ entry.messaging?.let { navigator.messages(it.packageName, it.label) }
}
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
fun CarLauncher() {
- val context = LocalContext.current
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
- val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
- value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
- }
+ val all = rememberAllEntries()
+ val running = rememberRunningApps()
+ val showVehicleData = rememberVehicleDataShown()
+ var showBadgeKey by remember { mutableStateOf(false) }
- val pinned by CarSettings.pinnedApps.collectAsState()
- val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
- val carSensorsEnabled by CarSettings.carSensorsEnabled.collectAsState()
-
- val leadingTiles = buildList {
- add(Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") })
- add(Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") })
- add(Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") })
- // Experimental: raw, unverified sensor labels - see CarSensors.
- if (experimentalFeatures && carSensorsEnabled) {
- add(Tile("Vehicle data", glyph = Icons.Filled.Info) { navigator.vehicleData() })
- }
- }
-
- val tiles = leadingTiles + installed.map { entry ->
- val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
+ fun tileOf(entry: Entry): Tile {
+ val pkg = entry.packageName
val id = entry.componentId
- Tile(
- entry.label, entry.icon,
+ return Tile(
+ entry.label, entry.icon, entry.builtIn?.glyph,
verified = pkg != null && KnownApps.works(pkg),
broken = pkg != null && KnownApps.isBroken(pkg),
partial = pkg != null && KnownApps.isPartial(pkg),
- pinned = id != null && id in pinned,
- onLongClick = id?.let { { CarSettings.togglePin(it) } },
- ) { launchEntry(entry, navigator, frame) }
- } + listOf(
- Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
- )
+ onLongClick = id?.let { { navigator.showAppMenu(it) } },
+ ) { openApp(entry, id?.let { running[it] }, navigator, frame) }
+ }
+
+ // Gearslip's own apps first, then everything installed, then Settings last where it's expected.
+ val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) }
+ val tiles = builtIns.filter { it.builtIn !== BuiltInApps.Settings }.map(::tileOf) +
+ all.filter { it.builtIn == null }.map(::tileOf) +
+ builtIns.filter { it.builtIn === BuiltInApps.Settings }.map(::tileOf)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
@@ -192,12 +218,23 @@ fun CarLauncher() {
) {
items(tiles) { AppTile(it) }
if (tiles.any { it.verified || it.broken || it.partial }) {
+ // Folded away behind one button: it's reference, read once, not something every
+ // visit to the launcher needs spelled out under the grid.
item(span = { GridItemSpan(maxLineSpan) }) {
- BadgeLegend(
- verified = tiles.any { it.verified },
- partial = tiles.any { it.partial },
- broken = tiles.any { it.broken },
- )
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) {
+ Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(if (showBadgeKey) "Hide badge key" else "What the badges mean")
+ }
+ if (showBadgeKey) {
+ BadgeLegend(
+ verified = tiles.any { it.verified },
+ partial = tiles.any { it.partial },
+ broken = tiles.any { it.broken },
+ )
+ }
+ }
}
}
}
@@ -263,17 +300,14 @@ private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
@Composable
private fun AppTile(tile: Tile) {
val shape = MaterialTheme.shapes.extraLarge
- Surface(
- modifier = Modifier
- .height(120.dp)
- .let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
- // combinedClickable, not Surface's own onClick, so a long press can mean something
- // different from a tap - pinning is deliberately the same gesture everywhere apps
- // are shown (here and, once pinned, the shortcut itself on the nav bar).
- .combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
+ // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
+ // shown, here and on the nav bar.
+ GsIconBox(
+ onClick = tile.onClick,
+ onLongClick = tile.onLongClick,
+ modifier = Modifier.height(120.dp),
+ colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape,
- color = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Box(Modifier.fillMaxSize()) {
Column(
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
index e3093fd..b235843 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
@@ -30,11 +30,16 @@ sealed interface CarScreen {
*/
data object Browse : CarScreen
+ /** A messaging app's recent conversations, read from the notifications it posts. */
+ data class Messages(val packageName: String) : CarScreen
+
/** [replyTo] is a notification key to open straight into its reply pane. */
data class Notifications(val replyTo: String? = null) : CarScreen
data class App(val id: String) : CarScreen
}
+enum class SidePanel { NONE, CONTROLS, LYRICS }
+
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
data class Crumb(val screen: CarScreen, val label: String)
@@ -76,15 +81,38 @@ class CarNavigator(startId: String) {
}
/**
- * The nav bar's now-playing pill is the player everywhere; this is only whether the home
- * screen is additionally showing lyrics for it in the column beside the map. Toggled by the
- * pill's own lyrics button, so it means the same thing wherever the pill is visible, even
- * though only the home screen actually has the room to show it.
+ * What the column beside the map on Home is showing, if anything. The player lives in one
+ * place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill
+ * steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back.
+ * Every other screen has no room for the column, so there the pill stays in the nav bar.
*/
- var lyricsOpen by mutableStateOf(false)
+ var sidePanel by mutableStateOf(SidePanel.NONE)
private set
- fun toggleLyrics() { lyricsOpen = !lyricsOpen }
+ /**
+ * The pill's dock button: moves the player beside the map, going Home first if need be. With
+ * lyrics already there it swaps them for the controls; with the controls there it closes.
+ */
+ fun togglePlayerDock() {
+ if (current != CarScreen.Home) {
+ sidePanel = SidePanel.CONTROLS
+ home()
+ return
+ }
+ sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS
+ }
+
+ fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
+
+ fun closeSidePanel() { sidePanel = SidePanel.NONE }
+
+ /** Component id of the app whose long-press menu (pin, close) is open, or null. */
+ var appMenu by mutableStateOf(null)
+ private set
+
+ fun showAppMenu(componentId: String) { appMenu = componentId }
+
+ fun dismissAppMenu() { appMenu = null }
fun media() { push(CarScreen.Media, "Media") }
@@ -100,7 +128,12 @@ class CarNavigator(startId: String) {
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
- fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
+ fun messages(packageName: String, label: String) { push(CarScreen.Messages(packageName), label) }
+
+ /** The list lives on the dashboard; only a reply gets a screen of its own. */
+ fun notifications(replyTo: String? = null) {
+ if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply")
+ }
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
index aa7e4db..ad1086e 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
@@ -117,6 +117,14 @@ object CarServices {
if (CarSettings.pipeAudio.value) CarAudio.request()
}
+ /** Closes the media app from the car: stops what it's playing and drops the connection. */
+ fun closeMedia() {
+ if (media.now.value.playing) media.togglePlay()
+ media.disconnect()
+ _mediaApp.value = null
+ autoplayPending = false
+ }
+
/** Called once the media app is connected: starts playback if that was asked for. */
fun autoplayIfDue() {
if (!autoplayPending) return
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
index b4aeb80..0371290 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
@@ -2,11 +2,16 @@ package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
+import app.seb3thehacker.gearslip.car.theme.GsTheme
+import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class NightMode { AUTO, DAY, NIGHT }
+/** Distances and temperatures. [AUTO] goes by the phone's region. */
+enum class Units { AUTO, IMPERIAL, METRIC }
+
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK }
@@ -24,11 +29,13 @@ object CarSettings {
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_THEME = "car_app_theme"
+ private const val KEY_UI_THEME = "car_ui_theme"
private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
+ private const val KEY_UNITS = "car_units"
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
* still be on the bar, not off the edge of a long list. */
@@ -48,11 +55,31 @@ object CarSettings {
private val autoplayFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow = themeFlow
+ private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
+
+ /** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
+ val uiTheme: StateFlow = uiThemeFlow
private val pinnedFlow = MutableStateFlow>(emptySet())
private val experimentalFeaturesFlow = MutableStateFlow(false)
private val voiceAssistantFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false)
private val seenSafetyWarningFlow = MutableStateFlow(false)
+ private val unitsFlow = MutableStateFlow(Units.AUTO)
+
+ val units: StateFlow = unitsFlow
+
+ /** Whether to show miles, feet and °F; [Units.AUTO] follows the countries that still use them. */
+ fun imperial(): Boolean = when (unitsFlow.value) {
+ Units.IMPERIAL -> true
+ Units.METRIC -> false
+ Units.AUTO -> java.util.Locale.getDefault().country in setOf("US", "LR", "MM")
+ }
+
+ fun setUnits(units: Units) {
+ unitsFlow.value = units
+ AppSettings.putString(app, KEY_UNITS, units.name)
+ Weather.refresh(app, force = true)
+ }
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
val pinnedApps: StateFlow> = pinnedFlow
@@ -119,6 +146,7 @@ object CarSettings {
themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE)
+ uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
nightFlow.value = runCatching {
@@ -130,6 +158,9 @@ object CarSettings {
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
+ unitsFlow.value = runCatching {
+ Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
+ }.getOrDefault(Units.AUTO)
}
fun setScale(value: Float) {
@@ -157,6 +188,11 @@ object CarSettings {
AppSettings.putString(app, KEY_THEME, theme.name)
}
+ fun setUiTheme(id: String) {
+ uiThemeFlow.value = id
+ AppSettings.putString(app, KEY_UI_THEME, id)
+ }
+
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
index f7be5e1..d56b940 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -30,6 +31,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import app.seb3thehacker.gearslip.car.theme.GsColors
+import app.seb3thehacker.gearslip.car.theme.GsIconBox
+import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
@@ -45,9 +49,11 @@ fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
+ val units by CarSettings.units.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
+ val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
@@ -85,12 +91,24 @@ fun CarSettingsScreen() {
theme,
) { CarSettings.setAppTheme(it) }
+ ChoiceRow(
+ "Button style",
+ GsThemes.builtIn.map { it.id to it.name },
+ uiTheme,
+ ) { CarSettings.setUiTheme(it) }
+
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
+ 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) }
+
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
@@ -141,18 +159,17 @@ private fun ChoiceRow(title: String, options: List>, selecte
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) ->
val on = value == selected
- Surface(
+ val scheme = MaterialTheme.colorScheme
+ GsIconBox(
onClick = { onSelect(value) },
modifier = Modifier
.weight(1f)
.height(64.dp),
- shape = MaterialTheme.shapes.large,
- color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
- contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
+ else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
+ latched = on,
) {
- Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
- Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
- }
+ Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
}
}
}
@@ -301,7 +318,7 @@ private fun ScreenFit(vehicle: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- Button(
+ GsButton(
onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) "Saved to $vehicle" else "Could not save"
@@ -332,14 +349,12 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
@Composable
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
- Surface(
+ GsIconBox(
onClick = onClick,
modifier = modifier.height(64.dp),
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
- Text(text, style = MaterialTheme.typography.titleLarge)
- }
+ Text(text, style = MaterialTheme.typography.titleLarge)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
index 7104e66..2cf8aad 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
@@ -13,20 +13,28 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.foundation.layout.BoxScope
+import androidx.car.app.model.CarIcon
+import androidx.car.app.navigation.model.NavigationTemplate
+import androidx.car.app.navigation.model.RoutingInfo
+import app.seb3thehacker.gearslip.host.CarAppConnection
+import app.seb3thehacker.gearslip.host.display
+import app.seb3thehacker.gearslip.host.text
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material3.Button
-import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -40,6 +48,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.animation.animateContentSize
+import android.media.session.PlaybackState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,6 +65,14 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import app.seb3thehacker.gearslip.R
+import app.seb3thehacker.gearslip.car.theme.GsColors
+import app.seb3thehacker.gearslip.car.theme.GsIconBox
+import app.seb3thehacker.gearslip.car.theme.GsIconButton
+import app.seb3thehacker.gearslip.car.theme.GsButton
+import app.seb3thehacker.gearslip.car.theme.GsTone
+import app.seb3thehacker.gearslip.car.theme.gsColors
+import app.seb3thehacker.gearslip.car.theme.GsThemes
+import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import androidx.compose.ui.platform.LocalContext
@@ -79,6 +99,7 @@ fun CarUi() {
val scale by CarSettings.scale.collectAsState()
val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState()
+ val uiThemeId by CarSettings.uiTheme.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext
@@ -120,6 +141,7 @@ fun CarUi() {
LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside,
LocalMapControlsWake provides controlsWake,
+ LocalGsTheme provides GsThemes.byId(uiThemeId),
) {
MaterialTheme(colorScheme = colors) {
Surface(
@@ -145,7 +167,8 @@ fun CarUi() {
}
val screen = navigator.current
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
- Column(Modifier.fillMaxSize().padding(pad)) {
+ Box(Modifier.fillMaxSize().padding(pad)) {
+ Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) {
CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen)
return@Column
@@ -163,10 +186,11 @@ fun CarUi() {
CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
+ is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
- // The history screen is already showing them, so it needs no popup.
- if (screen !is CarScreen.Notifications) {
+ // The dashboard is already listing them, so it needs no popup.
+ if (screen !is CarScreen.Notifications && screen != CarScreen.Dashboard) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
// Above the popup: a call is more urgent than any notification.
@@ -186,6 +210,9 @@ fun CarUi() {
val keyboardVisible by CarKeyboardVisibility.state
if (!keyboardVisible) CarNavBar(navigator)
}
+ // Over everything, nav bar included: a long press on an app, anywhere.
+ AppMenu(navigator, rememberRunningApps())
+ }
}
}
}
@@ -224,7 +251,7 @@ private fun CarSafetyWizard(onDone: () -> Unit) {
}
}
Spacer(Modifier.height(24.dp))
- Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
+ GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("I understand", style = ChromeType.body)
}
}
@@ -281,9 +308,7 @@ private fun BreadcrumbBar(navigator: CarNavigator) {
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
- }
+ GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
@@ -324,15 +349,13 @@ private fun CarNavBar(navigator: CarNavigator) {
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
- val context = LocalContext.current
val pinnedIds by CarSettings.pinnedApps.collectAsState()
- val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
- value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
- }
+ val entries = rememberAllEntries()
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
+ val running = rememberRunningApps()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
@@ -351,21 +374,49 @@ private fun CarNavBar(navigator: CarNavigator) {
NavAssistantIcon()
}
- pinnedEntries.forEach { entry ->
- Spacer(Modifier.width(6.dp))
- entry.componentId?.let { id ->
- NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
- }
- }
+ // Pinned shortcuts, then whatever else is open right now. A pinned app that's also
+ // open stays in its pinned spot, marked with a dot, instead of showing up twice.
+ val runningEntries = running.keys
+ .filter { it !in pinnedIds }
+ .mapNotNull { id -> entries.find { it.componentId == id } }
+ if (pinnedEntries.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
+ pinnedEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
+ if (pinnedEntries.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
+ runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
- Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
- NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
+ // Next turn first, off the map only; the player shares the space when it fits,
+ // and gives way to the turn when it doesn't.
+ val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
+ BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
+ val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ turn?.let { NavTurnPill(it) { navigator.home() } }
+ if (turn == null || roomForBoth) {
+ NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
+ }
+ }
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
- NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
+ // The clock is also the notifications button: the dashboard lists them under the
+ // calendar and weather, and a dot here marks anything unread.
+ val unread by CarNotifications.unread.collectAsState()
+ NavChip(
+ navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
+ { navigator.dashboard() },
+ ) {
+ if (unread > 0) {
+ Box(
+ Modifier
+ .align(Alignment.TopEnd)
+ .offset(x = 8.dp, y = (-2).dp)
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ )
+ }
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
@@ -393,49 +444,94 @@ private fun CarNavBar(navigator: CarNavigator) {
}
}
}
- Spacer(Modifier.width(10.dp))
- NavItem(
- Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
- ) { navigator.notifications() }
}
}
}
}
-/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
+/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
@Composable
-private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
- val context = LocalContext.current
- val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
- val icon by produceState(null, pkg) {
- value = pkg?.let {
- runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
- }
- }
+private fun NavDivider() {
Box(
Modifier
- .size(44.dp)
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant)
- .clickable(onClick = onClick),
- contentAlignment = Alignment.Center,
+ .padding(horizontal = 10.dp)
+ .width(1.dp)
+ .height(28.dp)
+ .background(MaterialTheme.colorScheme.outlineVariant),
+ )
+}
+
+/**
+ * A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
+ * already open; a long press opens its menu (pin, unpin, close).
+ */
+@Composable
+private fun NavEntryIcon(
+ entry: Entry,
+ running: Map,
+ navigator: CarNavigator,
+ frame: CarEnvironment.Frame,
+) {
+ val id = entry.componentId ?: return
+ val slot = running[id]
+ Box(Modifier.padding(horizontal = 3.dp)) {
+ NavAppIcon(
+ entry,
+ open = slot != null,
+ showing = slot != null && slot.isShowing(navigator.current),
+ onLongClick = { navigator.showAppMenu(id) },
+ ) { openApp(entry, slot, navigator, frame) }
+ }
+}
+
+/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
+@Composable
+private fun NavAppIcon(
+ entry: Entry,
+ open: Boolean,
+ showing: Boolean,
+ onLongClick: () -> Unit,
+ onClick: () -> Unit,
+) {
+ val scheme = MaterialTheme.colorScheme
+ GsIconBox(
+ onClick,
+ Modifier.size(44.dp),
+ colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
+ else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
+ latched = showing,
+ onLongClick = onLongClick,
) {
- icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
+ entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
+ entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
+ // Only running apps get the line along the bottom edge, the usual taskbar mark for it.
+ if (open) {
+ Box(
+ Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 2.dp)
+ .size(width = 12.dp, height = 3.dp)
+ .clip(RoundedCornerShape(50))
+ .background(scheme.primary),
+ )
+ }
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
-private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
- val scheme = MaterialTheme.colorScheme
- Box(
- Modifier
- .clip(RoundedCornerShape(18.dp))
- .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
- .clickable(onClick = onClick)
- .padding(horizontal = 10.dp, vertical = 6.dp),
- contentAlignment = Alignment.Center,
- ) { content() }
+private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
+ GsIconBox(onClick, colors = navColors(selected), latched = selected) {
+ Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
+ }
+}
+
+/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
+@Composable
+private fun navColors(selected: Boolean): GsColors {
+ val s = MaterialTheme.colorScheme
+ return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
+ else GsColors(s.surfaceContainerHighest, s.onSurface)
}
/**
@@ -446,32 +542,19 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable
@Composable
private fun NavAssistantIcon() {
val state by CarAssistant.state.collectAsState()
- val scheme = MaterialTheme.colorScheme
val active = state.phase != CarAssistant.Phase.IDLE
- Box(
- Modifier
- .clip(RoundedCornerShape(18.dp))
- .background(if (active) scheme.secondaryContainer else scheme.surfaceContainer)
- .clickable { CarAssistant.start() }
- .padding(12.dp),
- contentAlignment = Alignment.Center,
- ) {
- MicGlyph(Modifier.size(28.dp))
+ GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
+ MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
+/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
+private val NAV_ITEM_PADDING = 8.dp
+
@Composable
-private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
- val scheme = MaterialTheme.colorScheme
- Box(
- Modifier
- .clip(RoundedCornerShape(18.dp))
- .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
- .clickable(onClick = onClick)
- .padding(padding),
- contentAlignment = Alignment.Center,
- ) {
- Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
+private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
+ GsIconBox(onClick, colors = navColors(selected), latched = selected) {
+ Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
}
}
@@ -484,10 +567,83 @@ internal fun rememberNow(): State = produceState(System.currentTimeMillis(
}
}
+private const val NOW_PLAYING_MAX_CHARS = 30
+
+/** How long the pill shows a new song's title before settling to just the controls. */
+private const val TITLE_SHOWN_MS = 3_000L
+
+/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
+private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
+
+/** The next manoeuvre, as the running nav app last described it. */
+private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
+
/**
- * The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
- * full media screen; skip and play/pause work right here; the last button toggles the lyrics
- * panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
+ * Read straight off the nav app's own template: a navigation app keeps sending its routing card
+ * while it's running, whether or not the map is the screen being shown.
+ */
+@Composable
+private fun rememberNextTurn(): NextTurn? {
+ val status by CarServices.nav.status.collectAsState()
+ val template by CarServices.nav.template.collectAsState()
+ // Read so a change of units redraws the distance straight away, not at the next update.
+ CarSettings.units.collectAsState().value
+ if (status.phase != CarAppConnection.Phase.RUNNING) return null
+ val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
+ if (routing.isLoading) return null
+ val step = routing.currentStep ?: return null
+ return NextTurn(
+ icon = step.maneuver?.icon,
+ distance = routing.currentDistance.display(),
+ road = step.road.text().ifEmpty { step.cue.text() },
+ )
+}
+
+/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
+@Composable
+private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
+ // Same finish as every other button on the bar, in the accent colour; the text runs a step
+ // bigger than anything else there, since it's the one thing worth a glance mid-drive.
+ GsIconBox(
+ onClick,
+ Modifier.widthIn(max = 340.dp).height(44.dp),
+ colors = gsColors(GsTone.Primary),
+ ) {
+ Row(
+ Modifier.padding(NAV_ITEM_PADDING),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CarGlyph(turn.icon, Modifier.size(28.dp))
+ if (turn.distance.isNotEmpty()) {
+ Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
+ }
+ if (turn.road.isNotEmpty()) {
+ Text(
+ turn.road,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+/** "Title - Artist" on one line, cut to [NOW_PLAYING_MAX_CHARS] with "..." when it runs long. */
+private fun nowPlayingLabel(title: String, artist: String): String {
+ val full = listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
+ .ifEmpty { "Nothing playing" }
+ return if (full.length <= NOW_PLAYING_MAX_CHARS) full
+ else full.take(NOW_PLAYING_MAX_CHARS - 3).trimEnd() + "..."
+}
+
+/**
+ * The player in the nav bar, on every screen. Art and title open the full media screen;
+ * play/pause and skip work right here. The dock button moves the player into the column beside
+ * the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
+ * column is showing lyrics instead, when the pill stays and its dock button swaps them back.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
@@ -495,83 +651,104 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
+ // Docked beside the map, the player is already on screen - the pill steps aside for it.
+ if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
+ // The media screen is the full player already; the pill would only repeat its controls.
+ if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
+ val position by produceState(now.currentPosition(), now) {
+ while (true) {
+ value = now.currentPosition()
+ if (!now.playing) break
+ delay(1000)
+ }
+ }
+ // A new song announces itself: title, play/pause and dock for a few seconds, then the title
+ // goes and the pill settles to just the controls.
+ var showTitle by remember { mutableStateOf(true) }
+ LaunchedEffect(now.title, now.artist) {
+ showTitle = true
+ delay(TITLE_SHOWN_MS)
+ showTitle = false
+ }
+
+ val scheme = MaterialTheme.colorScheme
+ val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
- modifier = modifier,
- shape = RoundedCornerShape(50),
- color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
- contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = modifier
+ .animateContentSize()
+ .widthIn(max = 400.dp)
+ .then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
+ .height(44.dp),
+ shape = RoundedCornerShape(14.dp),
+ color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
+ contentColor = onPill,
) {
- Row(
- Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Row(
- Modifier
- .weight(1f, fill = false)
- .clip(RoundedCornerShape(50))
- .clickable { navigator.media() },
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) {
- art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
+ Box {
+ Row(
+ Modifier.fillMaxHeight().padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ // Art (and the title while it shows) opens the full media screen.
+ Row(
+ Modifier
+ .then(if (showTitle) Modifier.weight(1f) else Modifier)
+ .fillMaxHeight()
+ .clip(RoundedCornerShape(10.dp))
+ .clickable { navigator.media() },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
+ art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
+ }
+ if (showTitle) {
+ Text(
+ nowPlayingLabel(now.title, now.artist),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ if (!showTitle) {
+ GsIconButton(
+ MediaIcons.Previous, "Previous", media::previous,
+ enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
+ )
+ }
+ GsIconButton(
+ if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
+ if (now.playing) "Pause" else "Play",
+ media::togglePlay,
+ tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
+ )
+ if (!showTitle) {
+ GsIconButton(
+ MediaIcons.Next, "Next", media::next,
+ enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
+ )
+ }
+ GsIconButton(
+ MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
+ size = 36.dp, iconSize = 22.dp,
+ )
+ }
+ if (now.durationMs > 0) {
+ // matchParentSize, so the line follows the pill's width rather than widening it.
+ Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
+ Box(
+ Modifier
+ .fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
+ .height(2.dp)
+ .background(onPill.copy(alpha = 0.7f)),
+ )
+ }
}
- Text(
- now.title.ifEmpty { "Nothing playing" },
- modifier = Modifier.weight(1f, fill = false),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) {
- Icon(
- if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
- if (now.playing) "Pause" else "Play",
- Modifier.size(26.dp),
- )
- }
- FilledIconButton(
- onClick = navigator::toggleLyrics,
- modifier = Modifier.size(44.dp),
- colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
- containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
- else MaterialTheme.colorScheme.onSurfaceVariant,
- ),
- ) {
- LyricsGlyph(Modifier.size(22.dp))
- }
- }
- }
-}
-
-/**
- * A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
- * the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
- * rather than a vector asset since the whole glyph is two shapes.
- */
-@Composable
-private fun LyricsGlyph(modifier: Modifier = Modifier) {
- val tint = androidx.compose.material3.LocalContentColor.current
- androidx.compose.foundation.Canvas(modifier) {
- val stroke = size.minDimension * 0.11f
- drawRoundRect(
- color = tint,
- cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
- style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
- )
- val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
- val startX = size.width * 0.24f
- listOf(0.40f, 0.64f).forEach { fy ->
- val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
- drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
}
}
}
@@ -579,7 +756,7 @@ private fun LyricsGlyph(modifier: Modifier = Modifier) {
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
- * bolt, so this is drawn the same way [LyricsGlyph] is.
+ * bolt, so this is drawn rather than a vector asset.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
index 7bf3350..349d03b 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
@@ -1,5 +1,8 @@
package app.seb3thehacker.gearslip.car
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
@@ -27,10 +30,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material3.Button
-import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -47,7 +47,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
@@ -87,63 +89,70 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
// The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) }
- Column(Modifier.fillMaxSize()) {
- Row(
- Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") }
- Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
- Spacer(Modifier.weight(1f))
- AudioBadge(capture)
+ 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)) {
+ if (phase != CarMedia.Phase.READY) {
+ Column(Modifier.fillMaxSize()) {
+ AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
+ when (phase) {
+ CarMedia.Phase.REJECTED -> when (rejection) {
+ CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
+ "${app.label} keeps its library to itself.",
+ "Gearslip can still control it once it has Notification access on the phone. " +
+ "Allow it under Settings > Notifications > Notification access.",
+ action = "Open on phone" to {
+ val app = context.applicationContext
+ runCatching {
+ app.startActivity(
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+ },
+ )
+ CarMedia.Rejection.REFUSED -> Notice(
+ "${app.label} would not let Gearslip browse it.",
+ "Some media apps only accept Google's own host.",
+ )
+ }
+ else -> Notice("Connecting to ${app.label}…", null)
+ }
+ }
+ return@Surface
}
- when (phase) {
- CarMedia.Phase.REJECTED -> when (rejection) {
- CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
- "${app.label} keeps its library to itself.",
- "Gearslip can still control it once it has Notification access on the phone. " +
- "Allow it under Settings > Notifications > Notification access.",
- action = "Open on phone" to {
- val app = context.applicationContext
- runCatching {
- app.startActivity(
- Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
- )
- }
- },
- )
- CarMedia.Rejection.REFUSED -> Notice(
- "${app.label} would not let Gearslip browse it.",
- "Some media apps only accept Google's own host.",
- )
+ // Two columns, split the same way on every tab so nothing jumps when switching: the app
+ // and the art (or, beside a list, the compact player) on the left; the tabs across the
+ // top of the right column, over whatever the tab shows.
+ Row(
+ Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Column(Modifier.weight(0.8f).fillMaxHeight()) {
+ AppIdentity(app, onExit)
+ Spacer(Modifier.height(12.dp))
+ if (tab == Tab.NOW_PLAYING) {
+ CoverArt(art, Modifier.weight(1f).fillMaxWidth())
+ } else {
+ CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
+ }
+ AudioBadge(capture)
}
- CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
- CarMedia.Phase.READY -> Row(
- Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 6.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- // The art rail sits at the top, level with the tab row beside it, on every tab -
- // not under a full-width tab bar the way it used to be. Transport and the
- // progress bar stay on it always; only the app's extra buttons (shuffle, repeat,
- // like) are exclusive to the "Now playing" tab's own content area, since those are
- // the one thing that isn't already always visible here.
- NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight())
- Column(Modifier.weight(1f).fillMaxHeight()) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
- MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
- MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
- MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
- }
- Spacer(Modifier.height(8.dp))
- Box(Modifier.weight(1f).fillMaxWidth()) {
- when (tab) {
- Tab.NOW_PLAYING -> NowPlayingExtras(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())
- }
+ Column(Modifier.weight(1.2f).fillMaxHeight()) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
+ MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
+ MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
+ MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
+ }
+ Spacer(Modifier.height(12.dp))
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ when (tab) {
+ Tab.NOW_PLAYING -> NowPlayingControls(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())
}
}
}
@@ -151,21 +160,50 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
}
}
+/**
+ * Back, then which app this is: its own icon and its name, quieter than the song title - the
+ * name alone in big bold type read as one more heading fighting the track for attention.
+ */
+@Composable
+private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
+ val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
+ Row(modifier, verticalAlignment = Alignment.CenterVertically) {
+ GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
+ Spacer(Modifier.width(14.dp))
+ icon?.let {
+ Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
+ Spacer(Modifier.width(10.dp))
+ }
+ Text(
+ app.label.lowercase().replaceFirstChar { it.titlecase() },
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
@Composable
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
- Surface(
+ GsIconBox(
onClick = onClick,
- modifier = modifier.height(48.dp),
+ modifier = modifier.height(44.dp),
+ colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
+ else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large,
- color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
- contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ latched = selected,
) {
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
- Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
- }
+ Text(
+ label,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
}
@@ -174,7 +212,11 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
* the current line in the accent colour; unsynced ones are plain scrolling text.
*/
@Composable
-internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
+internal fun LyricsPanel(
+ now: NowPlaying,
+ modifier: Modifier,
+ lineStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleLarge,
+) {
val context = LocalContext.current
val lyrics by produceState(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
@@ -208,7 +250,7 @@ internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
itemsIndexed(lines) { index, line ->
Text(
line.text.ifEmpty { " " },
- style = MaterialTheme.typography.titleLarge,
+ style = lineStyle,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
@@ -255,115 +297,97 @@ private fun Notice(title: String, detail: String?, action: Pair Un
}
action?.let { (label, run) ->
Spacer(Modifier.height(12.dp))
- androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
+ GsButton(onClick = run, tone = GsTone.Tonal) { Text(label) }
}
}
}
-/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
-private fun formatDuration(ms: Long): String {
- val totalSeconds = (ms / 1000).coerceAtLeast(0)
- val hours = totalSeconds / 3600
- val minutes = (totalSeconds % 3600) / 60
- val seconds = totalSeconds % 60
- return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
-}
-
// --- now playing -----------------------------------------------------------------------------
/**
- * The art rail beside every tab: art, title, transport and the seek bar - always visible no
- * matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back
- * for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
+ * The now-playing view on its own, for places with no tabs around it (an app's own
+ * now-playing template): the cover on the left, [NowPlayingControls] on the right.
*/
@Composable
-internal fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) {
- val context = LocalContext.current
- val art by produceState(now.art, now.art, now.artUri) {
- value = now.art ?: MediaArt.load(context, now.artUri)
+internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
+ Row(
+ modifier.padding(horizontal = 24.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
+ NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
}
- var position by remember { mutableLongStateOf(0L) }
- LaunchedEffect(now) {
- while (true) {
- position = now.currentPosition()
- kotlinx.coroutines.delay(500)
+}
+
+/** The cover, as big a square as its space allows, centred in it. */
+@Composable
+private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
+ BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shadowElevation = 6.dp,
+ modifier = Modifier.size(minOf(maxWidth, maxHeight)),
+ ) {
+ art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
}
+}
- // Whether the progress bar (and its duration text) is showing depends on whether the app has
- // reported a duration yet, which can arrive a beat after everything else - so this rail's
- // content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit
- // once that row appears; scrolling means it's always reachable instead.
- BoxWithConstraints(modifier) {
- // Off the space actually available rather than a fixed dp value, so it doesn't overrun a
- // short car frame the way a flat size did on anything shorter than a phone screen.
- val artSize = (minOf(maxWidth, maxHeight * 0.45f) * 1.2f).coerceAtLeast(96.dp)
- Column(Modifier.verticalScroll(rememberScrollState())) {
- Surface(
- shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surfaceVariant,
- modifier = Modifier.size(artSize),
- ) {
- art?.let {
- Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
- }
- }
- Spacer(Modifier.height(16.dp))
+/**
+ * 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.
+ */
+@Composable
+private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
+ Column(
+ modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
+ ) {
+ Column {
+ Text(
+ now.title.ifEmpty { "Nothing playing" },
+ style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
+ maxLines = 2, overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
+ style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis,
+ )
+ now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
+ }
+ PlayerProgress(now, media, Modifier.fillMaxWidth())
+ TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
+ PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
+ }
+}
+
+/**
+ * The player beside the browse list, the queue and the lyrics: no art, which the list needs the
+ * room for, just what's playing, the seek bar and the controls.
+ */
+@Composable
+private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
+ Column(
+ modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Column {
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
- listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
+ now.artist,
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
- now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
- Spacer(Modifier.height(16.dp))
-
- if (now.durationMs > 0) {
- WavyProgress(
- fraction = position.toFloat() / now.durationMs,
- playing = now.state == PlaybackState.STATE_PLAYING,
- onSeek = { media.seek((it * now.durationMs).toLong()) },
- modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
- )
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- Spacer(Modifier.height(12.dp))
- }
-
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
- Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
- }
- FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
- Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
- }
- IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
- Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
- }
- }
- }
- }
-}
-
-/**
- * The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
- * name - their icons are resources inside the app's own package, so there's nothing to draw but
- * the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
- */
-@Composable
-private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
- Column(modifier, verticalArrangement = Arrangement.Center) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- now.custom.take(3).forEach { action ->
- TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
- }
}
+ PlayerProgress(now, media, Modifier.fillMaxWidth())
+ TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
+ PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
}
}
@@ -378,7 +402,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
- IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
+ GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
}
browse.trail.lastOrNull()?.let {
Text(
@@ -408,7 +432,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
- * whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps
+ * whole queue from the top, which would just repeat what the player beside it already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
@@ -482,7 +506,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) {
}
}
if (entry.browsable && entry.playable) {
- IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") }
+ GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
@@ -497,8 +521,13 @@ internal object MediaIcons {
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
+ val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
+ val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
+ val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
+ /** A screen with a filled right-hand panel: "put the player beside the map". */
+ val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
- private fun icon(name: String, path: String) = ImageVector.Builder(
+ private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
- ).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
+ ).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt
new file mode 100644
index 0000000..5d8dee5
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt
@@ -0,0 +1,181 @@
+package app.seb3thehacker.gearslip.car
+
+import app.seb3thehacker.gearslip.car.theme.*
+import android.content.Intent
+import android.provider.Settings
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import app.seb3thehacker.gearslip.notify.CarNotification
+import app.seb3thehacker.gearslip.notify.CarNotifications
+import app.seb3thehacker.gearslip.notify.ChatLine
+import java.text.DateFormat
+import java.util.Date
+
+/** Lines a collapsed conversation shows; tapping it shows the rest the app sent. */
+private const val COLLAPSED_LINES = 2
+
+/**
+ * A messaging app's recent conversations, the way Android Auto shows them: the app has no car
+ * screen of its own, so each thread comes from the notification it has on the phone, newest
+ * first, with reply and mark-as-read when the app offers them.
+ */
+@Composable
+fun MessagesScreen(packageName: String) {
+ val history by CarNotifications.history.collectAsState()
+ val listening by CarNotifications.listening.collectAsState()
+ val navigator = LocalCarNavigator.current
+ val context = LocalContext.current.applicationContext
+ val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
+ val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages"
+ val threads = history.filter { it.packageName == packageName }
+
+ LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) }
+
+ Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
+ entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
+ Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ }
+
+ if (!listening) {
+ AccessBanner {
+ context.startActivity(
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+ }
+
+ if (threads.isEmpty()) {
+ Text(
+ "No recent messages. New $label messages appear here as they arrive on the phone.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ contentPadding = PaddingValues(bottom = 16.dp),
+ ) {
+ items(threads, key = { it.key }) { n ->
+ ConversationCard(
+ n,
+ onReply = { navigator.notifications(replyTo = n.key) },
+ onMarkRead = { CarNotifications.markThreadRead(context, n) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationCard(n: CarNotification, onReply: () -> Unit, onMarkRead: () -> Unit) {
+ var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
+ // An app that posts plain notifications instead of a conversation still gets one line.
+ val lines = n.lines.ifEmpty { listOf(ChatLine(n.title.ifBlank { n.appLabel }, n.text.ifBlank { n.title }, n.postedAt)) }
+ val shown = if (expanded) lines else lines.takeLast(COLLAPSED_LINES)
+ val dim = n.read || n.replied
+
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize()
+ .clickable(enabled = lines.size > COLLAPSED_LINES) { expanded = !expanded },
+ ) {
+ Row(
+ Modifier.padding(14.dp),
+ verticalAlignment = Alignment.Top,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ NotificationIcon(n, size = 52.dp)
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ n.title.ifBlank { n.appLabel },
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = if (dim) FontWeight.Medium else FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false),
+ )
+ Text(
+ " · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ if (!expanded && lines.size > COLLAPSED_LINES) {
+ Text(
+ "${lines.size - COLLAPSED_LINES} earlier",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ shown.forEach { MessageLine(it, showSender = n.isGroup) }
+ }
+ Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ when {
+ n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
+ n.reply != null -> GsButton(onClick = onReply, tone = GsTone.Tonal) { Text("Reply") }
+ }
+ if (n.markRead != null) {
+ GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageLine(line: ChatLine, showSender: Boolean) {
+ val who = when {
+ line.sender == null -> "You: "
+ showSender -> "${line.sender}: "
+ else -> ""
+ }
+ Text(
+ who + line.text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (line.sender == null) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt
new file mode 100644
index 0000000..d74c4ac
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt
@@ -0,0 +1,239 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import app.seb3thehacker.gearslip.car.theme.GsButton
+import app.seb3thehacker.gearslip.car.theme.GsTone
+import app.seb3thehacker.gearslip.host.CarAppConnection
+import app.seb3thehacker.gearslip.media.CarMedia
+
+/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
+internal const val BUILT_IN_PREFIX = "gearslip:"
+
+/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
+internal class BuiltInApp(
+ val id: String,
+ val label: String,
+ val glyph: ImageVector,
+ val screen: CarScreen,
+ val open: (CarNavigator) -> Unit,
+)
+
+internal object BuiltInApps {
+ val Web = BuiltInApp("web", "Web", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
+ val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") }
+ val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
+ val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
+ val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
+
+ val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
+ val entries: List = all.map { Entry(it.label, icon = null, builtIn = it) }
+}
+
+/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
+@Composable
+internal fun rememberVehicleDataShown(): Boolean {
+ val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
+ val sensors by CarSettings.carSensorsEnabled.collectAsState()
+ return experimental && sensors
+}
+
+/**
+ * Where an open app lives - decides where tapping it goes and how it closes. Gearslip's own
+ * apps have no connection behind them, so [SCREEN] is simply "on screen right now".
+ */
+internal enum class RunningSlot { NAV, BROWSE, MEDIA, SCREEN }
+
+/**
+ * Apps open right now, as component id to where they live, in the order the nav bar shows them:
+ * the map, then a browsed templated app, then the media app, then whichever built-in app is on
+ * screen. The map and a browsed app count from the moment they start connecting until they're
+ * closed. The media app counts while it has something loaded to play - the same rule the nav
+ * bar's player follows, so the two always show up together - or while its own screen is up.
+ */
+@Composable
+internal fun rememberRunningApps(): Map {
+ val navStatus by CarServices.nav.status.collectAsState()
+ val browseStatus by CarServices.browse.status.collectAsState()
+ val mediaApp by CarServices.mediaApp.collectAsState()
+ val mediaPhase by CarServices.media.phase.collectAsState()
+ val now by CarServices.media.now.collectAsState()
+ val current = LocalCarNavigator.current.current
+
+ fun CarAppConnection.liveId(phase: CarAppConnection.Phase) =
+ connectedComponent?.flattenToString()?.takeIf {
+ phase == CarAppConnection.Phase.BINDING ||
+ phase == CarAppConnection.Phase.HANDSHAKE ||
+ phase == CarAppConnection.Phase.RUNNING
+ }
+
+ return buildMap {
+ CarServices.nav.liveId(navStatus.phase)?.let { put(it, RunningSlot.NAV) }
+ CarServices.browse.liveId(browseStatus.phase)?.let { put(it, RunningSlot.BROWSE) }
+ val mediaLive = when (mediaPhase) {
+ CarMedia.Phase.CONNECTING -> true
+ CarMedia.Phase.READY -> now.isActive || current == CarScreen.Media
+ else -> false
+ }
+ mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
+ BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
+ (current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
+ }
+}
+
+/** The screen an open app is showing on, so its nav bar icon can stay pressed in while it's up. */
+internal fun RunningSlot.isShowing(current: CarScreen): Boolean = when (this) {
+ RunningSlot.NAV -> current == CarScreen.Home
+ RunningSlot.BROWSE -> current == CarScreen.Browse
+ RunningSlot.MEDIA -> current == CarScreen.Media
+ RunningSlot.SCREEN -> true
+}
+
+/**
+ * Brings an app up: back to its screen if it's already open - never a reconnect, which would
+ * throw away the route or restart the session - otherwise a fresh launch.
+ */
+internal fun openApp(entry: Entry, slot: RunningSlot?, navigator: CarNavigator, frame: CarEnvironment.Frame) {
+ when (slot) {
+ RunningSlot.NAV -> navigator.home()
+ RunningSlot.BROWSE -> navigator.browse(entry.label)
+ RunningSlot.MEDIA -> navigator.media()
+ RunningSlot.SCREEN -> Unit
+ null -> launchEntry(entry, navigator, frame)
+ }
+}
+
+internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) {
+ when (slot) {
+ RunningSlot.NAV -> CarServices.stopNav()
+ RunningSlot.BROWSE -> CarServices.stopBrowse()
+ RunningSlot.MEDIA -> CarServices.closeMedia()
+ RunningSlot.SCREEN -> Unit
+ }
+ if (slot != RunningSlot.NAV && slot.isShowing(navigator.current)) navigator.back()
+}
+
+/**
+ * What a long press on an app offers - pin it to or unpin it from the nav bar, and close it if
+ * it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
+ * dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
+ * car's virtual display has no reason to host well.
+ */
+@Composable
+internal fun AppMenu(navigator: CarNavigator, running: Map) {
+ val id = navigator.appMenu ?: return
+ val entry = findEntry(id)
+ if (entry == null) {
+ navigator.dismissAppMenu()
+ return
+ }
+ val pinned by CarSettings.pinnedApps.collectAsState()
+ val isPinned = id in pinned
+ val barFull = !isPinned && pinned.size >= CarSettings.MAX_PINNED
+ val slot = running[id]
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.5f))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = navigator::dismissAppMenu,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ // Swallows taps on the card itself, so only the scrim around it dismisses.
+ modifier = Modifier
+ .widthIn(max = 420.dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ ),
+ ) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
+ entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
+ Text(
+ entry.label,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = 14.dp),
+ )
+ }
+ GsButton(
+ onClick = {
+ CarSettings.togglePin(id)
+ navigator.dismissAppMenu()
+ },
+ tone = GsTone.Tonal,
+ enabled = !barFull,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) {
+ Text(
+ when {
+ isPinned -> "Unpin from nav bar"
+ barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
+ else -> "Pin to nav bar"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ if (slot != null) {
+ GsButton(
+ onClick = {
+ closeApp(slot, navigator)
+ navigator.dismissAppMenu()
+ },
+ tone = GsTone.Danger,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
+ }
+ GsButton(
+ onClick = navigator::dismissAppMenu,
+ tone = GsTone.Neutral,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) { Text("Cancel", style = MaterialTheme.typography.titleMedium) }
+ }
+ }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt
index f01a27e..a1b4996 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt
@@ -1,9 +1,9 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -23,10 +21,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Warning
-import androidx.compose.material3.Button
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -35,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -52,34 +48,40 @@ import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
-/** The notification history, with a reply pane for anything the sending app lets us answer. */
+/**
+ * Replying to one notification. The list itself lives on the dashboard (see
+ * [NotificationsSection]); with nothing to reply to, this is just the dashboard.
+ */
@Composable
fun NotificationsScreen(replyTo: String?) {
val history by CarNotifications.history.collectAsState()
- var replying by remember(replyTo) { mutableStateOf(replyTo) }
-
- // Anything arriving while this is open is being looked at, so it never counts as unread.
- LaunchedEffect(history) { CarNotifications.markRead() }
-
- val target = history.firstOrNull { it.key == replying && it.reply != null }
+ val navigator = LocalCarNavigator.current
+ val target = history.firstOrNull { it.key == replyTo && it.reply != null }
if (target != null) {
- ReplyPane(target, onDone = { replying = null })
+ ReplyPane(target, onDone = navigator::back)
} else {
- NotificationList(history, onReply = { replying = it.key })
+ CarDashboardScreen()
}
}
+/**
+ * The notification history, under the calendar on the dashboard. Replying opens its own pane.
+ * Anything arriving while this is on screen is being looked at, so it never counts as unread.
+ */
@Composable
-private fun NotificationList(history: List, onReply: (CarNotification) -> Unit) {
+fun NotificationsSection() {
+ val history by CarNotifications.history.collectAsState()
val listening by CarNotifications.listening.collectAsState()
+ val navigator = LocalCarNavigator.current
val context = LocalContext.current.applicationContext
+ LaunchedEffect(history) { CarNotifications.markRead() }
- Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Notifications",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
@@ -94,22 +96,21 @@ private fun NotificationList(history: List, onReply: (CarNotifi
}
if (history.isEmpty()) {
- Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
- Text(
- "Nothing yet. New notifications from the phone will show up here.",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
+ Text(
+ "Nothing yet. New notifications from the phone will show up here.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
} else {
- LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- items(history, key = { it.key }) { NotificationRow(it, onReply) }
+ history.forEach { n ->
+ key(n.key) { NotificationRow(n) { navigator.notifications(replyTo = it.key) } }
}
}
}
}
@Composable
-private fun AccessBanner(onOpen: () -> Unit) {
+internal fun AccessBanner(onOpen: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
@@ -127,7 +128,7 @@ private fun AccessBanner(onOpen: () -> Unit) {
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
- Button(onClick = onOpen) { Text("Open") }
+ GsButton(onClick = onOpen) { Text("Open") }
}
}
}
@@ -160,14 +161,14 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
}
when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
- n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") }
+ n.reply != null -> GsButton(onClick = { onReply(n) }, tone = GsTone.Tonal) { Text("Reply") }
}
}
}
}
@Composable
-private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) {
+internal fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) {
val icon = n.icon
if (icon != null) {
Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp)))
@@ -194,7 +195,7 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
+ GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -250,7 +251,7 @@ fun NotificationPopup(modifier: Modifier = Modifier) {
modifier = modifier
.padding(top = 8.dp)
.fillMaxWidth(0.7f)
- .clickable { navigator.notifications() },
+ .clickable { navigator.dashboard() },
) {
Row(
Modifier.padding(12.dp),
@@ -268,11 +269,9 @@ fun NotificationPopup(modifier: Modifier = Modifier) {
}
}
if (n.reply != null) {
- FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") }
- }
- IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) {
- Icon(Icons.Filled.Close, contentDescription = "Dismiss")
+ GsButton(onClick = { navigator.notifications(replyTo = n.key) }, tone = GsTone.Tonal) { Text("Reply") }
}
+ GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) })
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt
index 094c00d..3c80b1d 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -96,19 +96,18 @@ private fun TouchRelayWarning() {
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
- Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
+ GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
}
}
}
@Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
- Surface(
+ GsIconBox(
onClick = onClick,
modifier = Modifier.height(120.dp),
+ colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = MaterialTheme.shapes.extraLarge,
- color = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
index eda9481..bf16606 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.Manifest
import android.content.Context
import android.content.Intent
@@ -20,17 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
-import androidx.compose.material3.Button
-import androidx.compose.material3.FilledIconButton
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -102,23 +100,27 @@ fun PhoneDialerScreen() {
var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Text(
- "Phone",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(vertical = 4.dp),
- )
+ // The tabs share the title's row, so the list and keypad get the height back.
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "Phone",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f),
+ )
+ if (hasPermissions) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
+ DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
+ }
+ }
+ }
if (!hasPermissions) {
MissingPermission(context)
return@Column
}
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
- DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
- }
-
Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
}
@@ -134,7 +136,7 @@ private fun MissingPermission(context: Context) {
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- FilledTonalButton(onClick = {
+ GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
@@ -144,16 +146,15 @@ private fun MissingPermission(context: Context) {
@Composable
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
- Surface(
+ GsIconBox(
onClick = onClick,
- modifier = modifier.height(52.dp),
+ modifier = modifier.height(44.dp),
+ colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
+ else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large,
- color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
- contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ latched = selected,
) {
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
- Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
- }
+ Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
@@ -193,19 +194,17 @@ private fun Keypad(context: Context) {
}
}
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- FilledTonalButton(
+ GsButton(
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
+ tone = GsTone.Tonal,
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
- ) { Text("⌫") }
- FilledIconButton(
+ ) { Text("⌫", style = MaterialTheme.typography.titleLarge) }
+ GsIconBox(
onClick = { placeCall(context, number) },
+ colors = gsColors(GsTone.Primary),
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
- colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- ),
) { Icon(Icons.Filled.Call, "Call") }
}
}
@@ -214,15 +213,13 @@ private fun Keypad(context: Context) {
@Composable
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
- Surface(
- onClick = onClick,
- modifier = modifier,
+ GsIconBox(
+ onClick,
+ modifier,
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
- Text(digit, style = MaterialTheme.typography.titleLarge)
- }
+ Text(digit, style = MaterialTheme.typography.titleLarge)
}
}
@@ -315,10 +312,10 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) {
+ GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
}
- Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
+ GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt
new file mode 100644
index 0000000..987b89c
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt
@@ -0,0 +1,236 @@
+package app.seb3thehacker.gearslip.car
+
+import android.media.session.PlaybackState
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import app.seb3thehacker.gearslip.media.ArtColor
+import app.seb3thehacker.gearslip.media.MediaArt
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.drawable.toBitmap
+import app.seb3thehacker.gearslip.car.theme.GsIconBox
+import app.seb3thehacker.gearslip.car.theme.GsIconButton
+import app.seb3thehacker.gearslip.car.theme.GsSeekBar
+import app.seb3thehacker.gearslip.car.theme.GsTone
+import app.seb3thehacker.gearslip.car.theme.gsColors
+import app.seb3thehacker.gearslip.media.CarMedia
+import app.seb3thehacker.gearslip.media.CustomAction
+import app.seb3thehacker.gearslip.media.NowPlaying
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+
+/*
+ * The pieces every player on the car screen is built from - the media screen, the column beside
+ * the map - so a button looks and behaves the same wherever it turns up.
+ */
+
+/** The seek bar, with elapsed and total time under it when [showTimes]. */
+@Composable
+internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier = Modifier, showTimes: Boolean = true) {
+ if (now.durationMs <= 0) return
+ var position by remember { mutableLongStateOf(now.currentPosition()) }
+ LaunchedEffect(now) {
+ while (true) {
+ position = now.currentPosition()
+ if (!now.playing) break
+ delay(500)
+ }
+ }
+ Column(modifier) {
+ GsSeekBar(
+ fraction = position.toFloat() / now.durationMs,
+ onSeek = { media.seek((it * now.durationMs).toLong()) },
+ enabled = now.canDo(PlaybackState.ACTION_SEEK_TO),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ )
+ if (showTimes) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
+
+/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
+internal fun formatDuration(ms: Long): String {
+ val totalSeconds = (ms / 1000).coerceAtLeast(0)
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+ return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
+}
+
+/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */
+@Composable
+internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
+ val play = skip * 1.25f
+ Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
+ GsIconButton(
+ MediaIcons.Previous, "Previous", media::previous,
+ enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
+ )
+ GsIconButton(
+ if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
+ if (now.playing) "Pause" else "Play",
+ media::togglePlay,
+ tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
+ )
+ GsIconButton(
+ MediaIcons.Next, "Next", media::next,
+ enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
+ )
+ }
+}
+
+/**
+ * Like, shuffle and repeat, then up to [maxOther] of the app's other buttons, all icon-only. One
+ * the app doesn't support stays on screen greyed out, so the row never shifts between apps.
+ * [trailing] adds buttons of the caller's own at the end (the side column's lyrics button).
+ */
+@Composable
+internal fun PlayModeRow(
+ now: NowPlaying,
+ media: CarMedia,
+ size: Dp,
+ modifier: Modifier = Modifier,
+ maxOther: Int = 0,
+ trailing: @Composable RowScope.() -> Unit = {},
+) {
+ val modes by media.modes.collectAsState()
+ val liked = now.liked
+ val shuffleKnown = modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_INVALID
+ val repeatKnown = modes.repeat != PlaybackStateCompat.REPEAT_MODE_INVALID
+ val shuffleOn = shuffleKnown && modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_NONE
+ val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE
+ val icon = size * 0.52f
+ Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
+ GsIconButton(
+ if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
+ if (liked == true) "Unlike" else "Like",
+ media::toggleLike,
+ enabled = liked != null,
+ tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
+ size = size, iconSize = icon,
+ )
+ GsIconButton(
+ MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on",
+ media::toggleShuffle,
+ enabled = shuffleKnown || now.shuffleAction != null,
+ tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
+ size = size, iconSize = icon,
+ )
+ GsIconButton(
+ if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
+ "Repeat",
+ media::cycleRepeat,
+ enabled = repeatKnown || now.repeatAction != null,
+ tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
+ size = size, iconSize = icon,
+ )
+ now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) }
+ trailing()
+ }
+}
+
+/**
+ * One of the app's own buttons, drawn with the app's own icon - it lives in the app's package,
+ * not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
+ */
+@Composable
+private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp) {
+ val icon = rememberActionIcon(action)
+ GsIconBox(
+ onClick = { media.custom(action) },
+ modifier = Modifier.size(size),
+ colors = gsColors(GsTone.Neutral),
+ ) {
+ if (icon != null) {
+ Icon(icon, action.name, Modifier.size(size * 0.52f))
+ } else {
+ Text(action.name, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
+
+@Composable
+private fun rememberActionIcon(action: CustomAction): ImageBitmap? {
+ val context = LocalContext.current
+ val app by CarServices.mediaApp.collectAsState()
+ val pkg = app?.component?.packageName
+ val icon by produceState(null, pkg, action.icon) {
+ value = if (pkg == null || action.icon == 0) null else withContext(Dispatchers.IO) {
+ runCatching {
+ context.packageManager.getResourcesForApplication(pkg)
+ .getDrawable(action.icon, null).toBitmap(96, 96).asImageBitmap()
+ }.getOrNull()
+ }
+ }
+ return icon
+}
+
+/** The track's art, from the session's bitmap or, failing that, its art URI. */
+@Composable
+internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? {
+ val context = LocalContext.current
+ val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
+ return art
+}
+
+/**
+ * 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.
+ */
+@Composable
+internal fun rememberArtColor(
+ art: android.graphics.Bitmap?,
+ fallback: Color,
+ darkLightness: Float = 0.2f,
+ lightLightness: Float = 0.86f,
+): Color {
+ val dark = fallback.luminance() < 0.5f
+ val tint by produceState(null, art) {
+ value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
+ }
+ 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
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
index 89eb951..5551709 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
@@ -1,9 +1,9 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import android.graphics.Rect
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -274,26 +273,20 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Box(
- Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.surfaceContainerHighest)
- // Not a disconnect: the connection stays up and the app decides its own next
- // screen. Ending navigation is its own call, separate from a back press - a
- // back press only pops the on-screen card, leaving the app still believing it
- // is navigating (so a freshly picked destination silently does nothing); this
- // is the call that actually clears that flag on the app's side.
- .clickable { CarServices.nav.stopNavigating() },
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- Icons.Filled.Close,
- contentDescription = "Stop navigating",
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(20.dp),
- )
- }
+ // Not a disconnect: the connection stays up and the app decides its own next screen.
+ // Ending navigation is its own call, separate from a back press - a back press only
+ // pops the on-screen card, leaving the app still believing it is navigating (so a
+ // freshly picked destination silently does nothing); this is the call that actually
+ // clears that flag on the app's side.
+ GsIconButton(
+ Icons.Filled.Close,
+ "Stop navigating",
+ { CarServices.nav.stopNavigating() },
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
+ size = 40.dp,
+ iconSize = 20.dp,
+ shape = CircleShape,
+ )
Spacer(Modifier.width(10.dp))
Column {
if (time.isNotEmpty()) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
index 591270d..1c20583 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
@@ -1,7 +1,6 @@
package app.seb3thehacker.gearslip.car
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -403,15 +402,12 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
template.headerAction?.let { ActionButton(it) }
template.tabs.forEach { tab ->
val selected = tab.contentId == active
- Surface(
- color = if (selected) MaterialTheme.colorScheme.primaryContainer
- else MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
- else MaterialTheme.colorScheme.onSurface,
+ GsIconBox(
+ onClick = { template.tabCallbackDelegate.tabSelected(tab.contentId) },
+ colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
+ else GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
shape = RoundedCornerShape(20.dp),
- modifier = Modifier.clickable {
- template.tabCallbackDelegate.tabSelected(tab.contentId)
- },
+ latched = selected,
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
@@ -553,7 +549,7 @@ private fun SectionRows(section: Section<*>, cacheKey: String) {
/**
* The template itself carries only a header - real Android Auto hosts draw this screen from the
* app's MediaSession instead, which is exactly what [CarServices.media] already does for the
- * media-app launcher and the bottom bar. Reusing [NowPlayingRail] here rather than a "not
+ * media-app launcher and the bottom bar. Reusing [NowPlayingPage] here rather than a "not
* supported" placeholder means an app whose own now-playing template Gearslip lands on (like
* Spotify's "Liked Songs" screen) still gets working transport controls.
*/
@@ -568,7 +564,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
template.header?.endHeaderActions.orEmpty(),
)
if (now.title.isNotEmpty()) {
- NowPlayingRail(now, media, Modifier.weight(1f).fillMaxWidth())
+ NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth())
} else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
index bdf9da1..c621949 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
+import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor
@@ -300,15 +301,17 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
}
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
- Surface(
- color = background,
- contentColor = content,
- shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
- modifier = modifier.clickable(enabled = action.isEnabled) {
+ GsIconBox(
+ onClick = {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
else action.onClickDelegate?.click("action")
},
+ modifier = modifier,
+ colors = GsColors(background, content),
+ enabled = action.isEnabled,
+ shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
+ latched = active,
) {
if (large && title.isNotEmpty()) {
// The label sits dead centre on the button; the icon hangs off the left edge, so the
@@ -368,13 +371,14 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
val hasGlyph = action.icon != null
- Surface(
+ GsIconBox(
+ onClick = { fire() },
+ modifier = modifier.fillMaxWidth(),
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
// behind never shows through however much of the five seconds is left.
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- contentColor = content,
+ colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, content),
+ enabled = action.isEnabled,
shape = RoundedCornerShape(28.dp),
- modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
) {
androidx.compose.foundation.layout.Box {
// The countdown itself: a plain fill sweeping left to right underneath the label.
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt
deleted file mode 100644
index 5384a71..0000000
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-package app.seb3thehacker.gearslip.car
-
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.gestures.detectHorizontalDragGestures
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.unit.dp
-import kotlin.math.PI
-import kotlin.math.sin
-
-/**
- * A progress bar whose played part is a wave, and which flattens out while paused.
- *
- * The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or
- * dragging seeks; the position is committed on release rather than on every pixel of a drag,
- * because each commit is a request to the media app.
- */
-@Composable
-fun WavyProgress(
- fraction: Float,
- playing: Boolean,
- onSeek: (Float) -> Unit,
- modifier: Modifier = Modifier,
-) {
- val played = MaterialTheme.colorScheme.primary
- val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
- val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave")
- val phase = if (playing) {
- rememberInfiniteTransition(label = "phase").animateFloat(
- 0f, (2 * PI).toFloat(),
- infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
- label = "phase",
- ).value
- } else 0f
-
- var dragging by remember { mutableStateOf(null) }
- val shown = (dragging ?: fraction).coerceIn(0f, 1f)
-
- Canvas(
- modifier
- .fillMaxWidth()
- .height(32.dp)
- .pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
- .pointerInput(Unit) {
- detectHorizontalDragGestures(
- onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
- onDragEnd = { dragging?.let(onSeek); dragging = null },
- onDragCancel = { dragging = null },
- ) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
- },
- ) {
- val stroke = 4.dp.toPx()
- val mid = size.height / 2
- val end = size.width * shown
- val wavelength = 22.dp.toPx()
- val amp = 3.5.dp.toPx() * amplitude
-
- // Unplayed part: a straight line, so the wave reads as "this much is done".
- drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round)
-
- val wave = Path().apply {
- moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat())
- var x = 0f
- while (x < end) {
- x = minOf(end, x + 2f)
- lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat())
- }
- }
- drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round))
- drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round)
- }
-}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt
index 5d7013b..5c9ea2d 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt
@@ -136,7 +136,7 @@ object Weather {
// --- network --------------------------------------------------------------------------
private fun fetch(context: Context, location: Location): WeatherData {
- val imperial = Locale.getDefault().country in setOf("US", "LR", "MM")
+ val imperial = CarSettings.imperial()
val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt
new file mode 100644
index 0000000..eca8a87
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt
@@ -0,0 +1,326 @@
+package app.seb3thehacker.gearslip.car.theme
+
+import android.os.SystemClock
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.offset
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.translate
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** What a control is for, which picks its colours from the Material scheme. */
+enum class GsTone { Primary, Tonal, Neutral, Outline, Danger }
+
+data class GsColors(val container: Color, val content: Color, val border: Color = Color.Unspecified)
+
+@Composable
+fun gsColors(tone: GsTone): GsColors {
+ val s = MaterialTheme.colorScheme
+ return when (tone) {
+ GsTone.Primary -> GsColors(s.primary, s.onPrimary)
+ GsTone.Tonal -> GsColors(s.secondaryContainer, s.onSecondaryContainer)
+ GsTone.Neutral -> GsColors(s.surfaceContainerHighest, s.onSurfaceVariant)
+ GsTone.Outline -> GsColors(Color.Transparent, s.primary, s.outline)
+ GsTone.Danger -> GsColors(s.error, s.onError)
+ }
+}
+
+@Composable
+fun gsShape(): Shape = RoundedCornerShape(gsLook().corner)
+
+/** The current theme's control look, swapped for its light-mode variant on a light UI. */
+@Composable
+fun gsLook(): ControlLook {
+ val look = LocalGsTheme.current.control
+ val light = look.light ?: return look
+ return if (MaterialTheme.colorScheme.surface.luminance() > 0.5f) light else look
+}
+
+/**
+ * Paints a control's container in the current theme's finish. Transparent containers stay
+ * transparent in every theme. Use directly for custom-shaped tappables; [GsButton] and
+ * [GsIconButton] already apply it. [latched] holds a control down - a selected option, the current
+ * screen's nav button - in themes that have a pressed-in look.
+ */
+@Composable
+fun Modifier.gsSurface(
+ container: Color,
+ shape: Shape = gsShape(),
+ border: Color = Color.Unspecified,
+ pressed: Boolean = false,
+ latched: Boolean = false,
+): Modifier {
+ val look = gsLook()
+ val down = pressed || latched
+ // The keycap's face sinks onto its base while pressed, and stays down while latched: the base
+ // shrinks to nothing and the face moves down by the same amount, so the control's overall size
+ // never changes.
+ val lipNow by animateDpAsState(if (down) 0.dp else look.lip, tween(if (pressed) 50 else 110), label = "keycap")
+ // Tactile: the drop shadow collapses and the face drops by [ControlLook.sink].
+ val lift by animateFloatAsState(if (down) 0f else 1f, tween(if (pressed) 50 else 140), label = "lift")
+ val sinkNow by animateDpAsState(if (down) look.sink else 0.dp, tween(if (pressed) 50 else 140), label = "sink")
+ var m = this
+ if (container.alpha > 0f && look.shadow > 0.dp) {
+ m = m.drawBehind { drawSoftShadow(shape, look.shadow.toPx(), look.shadowAlpha * lift, lift) }
+ }
+ if (look.sink > 0.dp) m = m.offset(y = sinkNow)
+ if (container.alpha > 0f && look.lip > 0.dp) {
+ val base = lerp(container, Color.Black, 0.35f)
+ m = m
+ .padding(top = look.lip - lipNow, bottom = lipNow)
+ .drawBehind {
+ translate(top = lipNow.toPx()) {
+ drawOutline(shape.createOutline(size, layoutDirection, this), base)
+ }
+ }
+ }
+ m = m.clip(shape)
+ if (container.alpha > 0f) {
+ val shade = if (container.luminance() > 0.5f) look.bottomShadeOnLight else look.bottomShade
+ m = if (look.topLight > 0f || shade > 0f) {
+ m.background(
+ Brush.verticalGradient(
+ 0f to lerp(container, Color.White, look.topLight),
+ 0.5f to container,
+ 1f to lerp(container, Color.Black, shade),
+ ),
+ )
+ } else {
+ m.background(container)
+ }
+ if (latched && look.insetAlpha > 0f) {
+ val inset = look.insetAlpha
+ m = m.drawBehind {
+ drawRect(Brush.verticalGradient(0f to Color.Black.copy(alpha = inset), 0.3f to Color.Transparent))
+ }
+ }
+ if (look.rimAlpha > 0f) {
+ m = m.border(1.dp, Brush.verticalGradient(listOf(Color.White.copy(alpha = look.rimAlpha), Color.Transparent)), shape)
+ }
+ if (!latched && (look.bevelTop > 0f || look.bevelBottom > 0f)) {
+ m = m.border(
+ 1.dp,
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = look.bevelTop),
+ 0.3f to Color.Transparent,
+ 0.7f to Color.Transparent,
+ 1f to Color.Black.copy(alpha = look.bevelBottom),
+ ),
+ shape,
+ )
+ }
+ }
+ if (border.isSpecified) m = m.border(BorderStroke(1.dp, border), shape)
+ if (pressed) m = m.drawWithContent {
+ drawContent()
+ drawRect(Color.Black.copy(alpha = look.pressedDim))
+ }
+ return m
+}
+
+/**
+ * A blurred shadow falling [elevation] below the control, faked with a few stacked, growing
+ * outlines: cheap, works on every API level, and already soft enough that the car's video
+ * compression has nothing crisp to smear.
+ */
+private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) {
+ if (alpha <= 0f) return
+ val blur = elevation * 2f
+ val steps = 6
+ for (i in steps downTo 1) {
+ val grow = blur * i / steps - blur * 0.35f
+ val outline = shape.createOutline(
+ Size((size.width + grow * 2).coerceAtLeast(0f), (size.height + grow * 2).coerceAtLeast(0f)),
+ layoutDirection,
+ this,
+ )
+ translate(-grow, elevation * lift - grow) {
+ drawOutline(outline, Color.Black.copy(alpha = alpha / steps))
+ }
+ }
+}
+
+/**
+ * Whether a control should look pressed. Held for at least [MIN_PRESS_MS] after the finger lifts:
+ * a quick tap would otherwise come and go between two frames of the car's video stream.
+ */
+@Composable
+private fun rememberPressed(source: MutableInteractionSource): Boolean {
+ var shown by remember { mutableStateOf(false) }
+ LaunchedEffect(source) {
+ var since = 0L
+ var release: Job? = null
+ source.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> {
+ release?.cancel()
+ since = SystemClock.uptimeMillis()
+ shown = true
+ }
+ is PressInteraction.Release, is PressInteraction.Cancel -> {
+ release?.cancel()
+ release = launch {
+ delay((MIN_PRESS_MS - (SystemClock.uptimeMillis() - since)).coerceAtLeast(0))
+ shown = false
+ }
+ }
+ }
+ }
+ }
+ return shown
+}
+
+private const val MIN_PRESS_MS = 140L
+
+@Composable
+private fun Modifier.gsPressable(
+ source: MutableInteractionSource,
+ pressed: Boolean,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ onLongClick: (() -> Unit)? = null,
+ role: Role = Role.Button,
+): Modifier {
+ val look = gsLook()
+ return this
+ .scale(if (pressed) look.pressedScale else 1f)
+ .combinedClickable(
+ interactionSource = source,
+ indication = null,
+ enabled = enabled,
+ role = role,
+ onLongClick = onLongClick,
+ onClick = onClick,
+ )
+}
+
+private fun GsColors.disabled(enabled: Boolean, onSurface: Color): GsColors =
+ if (enabled) this
+ else GsColors(
+ container = if (container.alpha > 0f) onSurface.copy(alpha = 0.12f) else container,
+ content = onSurface.copy(alpha = 0.38f),
+ border = if (border.isSpecified) onSurface.copy(alpha = 0.12f) else border,
+ )
+
+/** A themed text (or text-and-icon) button. */
+@Composable
+fun GsButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ tone: GsTone = GsTone.Primary,
+ colors: GsColors = gsColors(tone),
+ enabled: Boolean = true,
+ contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
+ content: @Composable RowScope.() -> Unit,
+) {
+ val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
+ val shape = gsShape()
+ val source = remember { MutableInteractionSource() }
+ val pressed = rememberPressed(source)
+ Row(
+ modifier
+ .defaultMinSize(minWidth = 64.dp, minHeight = 48.dp)
+ .gsPressable(source, pressed, enabled, onClick)
+ .gsSurface(c.container, shape, c.border, pressed)
+ .padding(contentPadding),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CompositionLocalProvider(LocalContentColor provides c.content) {
+ ProvideTextStyle(MaterialTheme.typography.labelLarge) { content() }
+ }
+ }
+}
+
+/** A themed square icon button. Pass a [modifier] size to override the default [size]. */
+@Composable
+fun GsIconButton(
+ icon: ImageVector,
+ contentDescription: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ tone: GsTone = GsTone.Neutral,
+ colors: GsColors = gsColors(tone),
+ enabled: Boolean = true,
+ size: Dp = 48.dp,
+ iconSize: Dp = 24.dp,
+ shape: Shape = gsShape(),
+) {
+ GsIconBox(onClick, modifier.size(size), colors, enabled, shape) {
+ Icon(icon, contentDescription, Modifier.size(iconSize))
+ }
+}
+
+/** [GsIconButton] with arbitrary content, for glyphs drawn in code or app icons. */
+@Composable
+fun GsIconBox(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ colors: GsColors = gsColors(GsTone.Neutral),
+ enabled: Boolean = true,
+ shape: Shape = gsShape(),
+ /** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
+ latched: Boolean = false,
+ onLongClick: (() -> Unit)? = null,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface)
+ val source = remember { MutableInteractionSource() }
+ val pressed = rememberPressed(source)
+ Box(
+ modifier
+ .gsPressable(source, pressed, enabled, onClick, onLongClick)
+ .gsSurface(c.container, shape, c.border, pressed, latched),
+ contentAlignment = Alignment.Center,
+ ) {
+ CompositionLocalProvider(LocalContentColor provides c.content) { content() }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt
new file mode 100644
index 0000000..0f5737b
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt
@@ -0,0 +1,83 @@
+package app.seb3thehacker.gearslip.car.theme
+
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.dp
+
+/**
+ * A seek bar in the current theme's finish: the track is set into the surface (drawn latched, so
+ * themes with a pressed-in look sink it), the played part and the knob are raised like a button.
+ * Tapping or dragging seeks; the position is committed on release rather than on every pixel of a
+ * drag, because each commit is a request to the media app.
+ */
+@Composable
+fun GsSeekBar(
+ fraction: Float,
+ onSeek: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val scheme = MaterialTheme.colorScheme
+ var dragging by remember { mutableStateOf(null) }
+ val shown = (dragging ?: fraction).coerceIn(0f, 1f)
+ val track = 8.dp
+ val knob = 20.dp
+
+ BoxWithConstraints(
+ modifier
+ .fillMaxWidth()
+ .height(32.dp)
+ .then(
+ if (!enabled) Modifier else Modifier
+ .pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
+ onDragEnd = { dragging?.let(onSeek); dragging = null },
+ onDragCancel = { dragging = null },
+ ) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
+ },
+ ),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ // The knob's centre travels from half a knob in to half a knob from the end, so it never
+ // hangs off either edge; the fill runs to its centre.
+ val travel = maxWidth - knob
+ val centre = knob / 2 + travel * shown
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(track)
+ .gsSurface(scheme.onSurface.copy(alpha = 0.14f), CircleShape, latched = true),
+ )
+ Box(
+ Modifier
+ .width(centre)
+ .height(track)
+ .gsSurface(scheme.primary, CircleShape),
+ )
+ Box(
+ Modifier
+ .offset(x = centre - knob / 2)
+ .size(knob)
+ .gsSurface(scheme.primary, CircleShape, pressed = dragging != null),
+ )
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt
new file mode 100644
index 0000000..ba351ce
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt
@@ -0,0 +1,114 @@
+package app.seb3thehacker.gearslip.car.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * How the car UI's controls look, separate from its colours: [MaterialTheme]'s colour scheme still
+ * decides what colour a button is, and this decides how that colour is drawn. A theme is plain
+ * data, so a user-supplied theme later is just another instance - nothing about the built-in ones
+ * is special beyond being listed in [GsThemes.builtIn].
+ */
+@Immutable
+data class GsTheme(
+ /** Stable key persisted in settings; never reuse one for a different look. */
+ val id: String,
+ val name: String,
+ val control: ControlLook,
+)
+
+/** The finish on anything tappable that has a container: buttons, toggles, keypad keys. */
+@Immutable
+data class ControlLook(
+ val corner: Dp = 12.dp,
+ /** How far the top edge is lifted towards white, 0..1, fading evenly to the base colour at the centre. */
+ val topLight: Float = 0f,
+ /** How far the bottom edge is pushed towards black, 0..1. */
+ val bottomShade: Float = 0f,
+ /**
+ * [bottomShade] for containers that are already light, where lifting the top towards white
+ * barely shows: those get their depth from a darker bottom edge instead.
+ */
+ val bottomShadeOnLight: Float = 0f,
+ /** Alpha of the thin highlight along the top rim, fading out down the sides; 0 for none. */
+ val rimAlpha: Float = 0f,
+ /** A solid darker base under the button, like a keycap; 0 for none. */
+ val lip: Dp = 0.dp,
+ /** How far a soft drop shadow falls below the button; 0 for none. Its blur is twice this. */
+ val shadow: Dp = 0.dp,
+ val shadowAlpha: Float = 0f,
+ /**
+ * A 1px bevel on the edge only - light along the top, dark along the bottom - so depth reads
+ * at any size without shading the face itself. 0 for none.
+ */
+ val bevelTop: Float = 0f,
+ val bevelBottom: Float = 0f,
+ /** How far the face drops while pressed; 0 for none. */
+ val sink: Dp = 0.dp,
+ /**
+ * Alpha of the shadow cast inside the top edge of a latched control (a selected option, the
+ * current screen's nav button), so it reads as pushed in. 0 for none.
+ */
+ val insetAlpha: Float = 0f,
+ /**
+ * Alpha of the dark overlay while pressed. Heavier than a ripple on purpose: the car screen
+ * shows a video stream a frame or two behind the finger, so subtle feedback gets lost.
+ */
+ val pressedDim: Float = 0.22f,
+ /** Scale while pressed, 1 for none. */
+ val pressedScale: Float = 0.95f,
+ /** Replaces this look when the UI is light, for effects that only work on one background. */
+ val light: ControlLook? = null,
+)
+
+object GsThemes {
+ val Keycap = GsTheme(
+ id = "keycap",
+ name = "Keycap",
+ control = ControlLook(lip = 4.dp, pressedScale = 1f),
+ )
+
+ val Tactile = GsTheme(
+ id = "tactile",
+ name = "Tactile",
+ control = ControlLook(
+ topLight = 0.05f,
+ shadow = 3.dp,
+ shadowAlpha = 0.32f,
+ bevelTop = 0.16f,
+ bevelBottom = 0.22f,
+ sink = 2.dp,
+ insetAlpha = 0.30f,
+ pressedDim = 0.12f,
+ pressedScale = 1f,
+ // A wide soft shadow bands once the car's video stream compresses it on a light
+ // background, so light mode gets a tight one and leans on the bevel instead.
+ light = ControlLook(
+ topLight = 0.05f,
+ shadow = 1.5.dp,
+ shadowAlpha = 0.38f,
+ bevelTop = 0.24f,
+ bevelBottom = 0.30f,
+ sink = 2.dp,
+ insetAlpha = 0.30f,
+ pressedDim = 0.12f,
+ pressedScale = 1f,
+ ),
+ ),
+ )
+
+ val Flat = GsTheme(
+ id = "flat",
+ name = "Flat",
+ control = ControlLook(),
+ )
+
+ val default = Tactile
+ val builtIn = listOf(Tactile, Keycap, Flat)
+
+ fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default
+}
+
+val LocalGsTheme = staticCompositionLocalOf { GsThemes.default }
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
index 9217903..55000ab 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
@@ -108,7 +108,9 @@ class CarAppConnection(private val context: Context) {
// --- connection ---------------------------------------------------------------------
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int, destination: String? = null) {
- disconnect()
+ // Keeps the location hold through a reconnect: stopping and restarting the service can be
+ // refused once the phone is locked, leaving the new app without location.
+ disconnect(keepLocation = true)
frameWidth = width
frameHeight = height
frameDensity = densityDpi
@@ -133,8 +135,9 @@ class CarAppConnection(private val context: Context) {
}
binding = connection
- LocationKeepAlive.start(context)
- val bind = {
+ val bind = bind@{
+ // Replaced by another connect (or a disconnect) while waiting on the location service.
+ if (binding !== connection) return@bind
var bindError: Throwable? = null
val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
@@ -151,9 +154,20 @@ class CarAppConnection(private val context: Context) {
}
}
- bind()
+ // BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind
+ // time, so the keep-alive has to be running first - a nav app that starts its own
+ // location service before then is refused, and MapQuest crashes on that.
+ if (holdsLocation) {
+ bind()
+ } else {
+ holdsLocation = true
+ LocationKeepAlive.acquire(context) { bind() }
+ }
}
+ /** Whether this connection has a hold on [LocationKeepAlive]. */
+ private var holdsLocation = false
+
/**
* A service that names a permission can only be bound by an app holding it. Some navigation apps
* name one that only Google's own Android Auto host holds, and no other app can be granted.
@@ -187,7 +201,9 @@ class CarAppConnection(private val context: Context) {
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
}
- fun disconnect() {
+ fun disconnect() = disconnect(keepLocation = false)
+
+ private fun disconnect(keepLocation: Boolean) {
lent?.let { sendSurfaceDestroyed(it) }
lent = null
val carApp = this.carApp
@@ -195,7 +211,10 @@ class CarAppConnection(private val context: Context) {
runCatching { carApp.onAppPause(noop("onAppPause")) }
runCatching { carApp.onAppStop(noop("onAppStop")) }
}
- LocationKeepAlive.stop(context)
+ if (holdsLocation && !keepLocation) {
+ holdsLocation = false
+ LocationKeepAlive.release(context)
+ }
binding?.let { runCatching { context.unbindService(it) } }
binding = null
this.carApp = null
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt
new file mode 100644
index 0000000..b411957
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt
@@ -0,0 +1,42 @@
+package app.seb3thehacker.gearslip.host
+
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.Bundle
+import org.xmlpull.v1.XmlPullParser
+
+/**
+ * An app's Android Auto descriptor: the `com.google.android.gms.car.application` meta-data,
+ * pointing at an xml file whose `` lines say what the app does in a car -
+ * `media`, `notification` (messaging), `template` and so on.
+ */
+object CarDescriptor {
+
+ const val META = "com.google.android.gms.car.application"
+
+ /** The meta-data holding the descriptor, from the service first and then the app. */
+ fun meta(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Bundle? =
+ service?.metaData?.takeIf { it.containsKey(META) }
+ ?: runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData }
+ .getOrNull()?.takeIf { it.containsKey(META) }
+
+ /**
+ * The `uses` names in [pkg]'s descriptor. Null when it has no descriptor, or one that can't
+ * be read; empty when it lists nothing.
+ */
+ fun uses(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Set? {
+ val resId = meta(manager, pkg, service)?.getInt(META) ?: return null
+ if (resId == 0) return null
+ return runCatching {
+ manager.getResourcesForApplication(pkg).getXml(resId).use { xml ->
+ buildSet {
+ while (xml.next() != XmlPullParser.END_DOCUMENT) {
+ if (xml.eventType == XmlPullParser.START_TAG && xml.name == "uses") {
+ xml.getAttributeValue(null, "name")?.let(::add)
+ }
+ }
+ }
+ }
+ }.getOrNull()
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
index 8ab1a90..c67492e 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
/**
* Turns the Car App Library's model objects into things Compose can draw.
@@ -102,9 +103,16 @@ fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
else -> default
}
-/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */
+/**
+ * "500 ft", "2.4 mi". The app picks the unit and its rounding; when that's the other system from
+ * the one chosen in Gearslip's settings, the distance is converted and rounded here instead.
+ */
fun Distance?.display(): String {
val distance = this ?: return ""
+ val appImperial = displayUnit in setOf(Distance.UNIT_MILES, Distance.UNIT_MILES_P1, Distance.UNIT_FEET, Distance.UNIT_YARDS)
+ val appMetric = displayUnit in setOf(Distance.UNIT_METERS, Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1)
+ val imperial = app.seb3thehacker.gearslip.car.CarSettings.imperial()
+ if ((imperial && appMetric) || (!imperial && appImperial)) return convertDistance(distance.meters(), imperial)
val unit = when (displayUnit) {
Distance.UNIT_METERS -> "m"
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
@@ -121,6 +129,32 @@ fun Distance?.display(): String {
return "%.${decimals}f $unit".format(distance.displayDistance)
}
+private fun Distance.meters(): Double = displayDistance * when (displayUnit) {
+ Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
+ Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
+ Distance.UNIT_FEET -> 0.3048
+ Distance.UNIT_YARDS -> 0.9144
+ else -> 1.0
+}
+
+/**
+ * Rounded the way a nav app would: feet in steps of 50 under a tenth of a mile, metres in steps
+ * of 10 (50 past 300) under a kilometre, one decimal under 10 miles or km, whole numbers above.
+ */
+private fun convertDistance(meters: Double, imperial: Boolean): String {
+ if (imperial) {
+ val miles = meters / 1609.344
+ if (miles < 0.1) return "${(meters / 0.3048 / 50).roundToInt() * 50} ft"
+ return if (miles < 10) "%.1f mi".format(miles) else "%.0f mi".format(miles)
+ }
+ if (meters < 1000) {
+ val step = if (meters < 300) 10 else 50
+ return "${(meters / step).roundToInt() * step} m"
+ }
+ val km = meters / 1000
+ return if (km < 10) "%.1f km".format(km) else "%.0f km".format(km)
+}
+
/** "18 min" from the app's remaining-time estimate. */
fun remainingTime(seconds: Long): String {
if (seconds <= 0) return ""
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt
index e532e45..2932865 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt
@@ -9,7 +9,9 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
/**
@@ -41,13 +43,23 @@ class LocationKeepAlive : Service() {
// it; a nav app losing its background location fix is a small loss next to that.
runCatching {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
+ }.onSuccess {
+ settle(State.ON)
}.onFailure {
GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}")
+ settle(State.FAILED)
stopSelf()
}
return START_NOT_STICKY
}
+ override fun onDestroy() {
+ if (state == State.ON) state = State.OFF
+ super.onDestroy()
+ }
+
+ enum class State { OFF, STARTING, ON, FAILED }
+
companion object {
private const val CHANNEL = "gearslip_location"
private const val NOTIFICATION_ID = 44
@@ -55,14 +67,73 @@ class LocationKeepAlive : Service() {
/** Public flag value; the constant itself is hidden from the SDK. */
const val BIND_INCLUDE_CAPABILITIES = 0x1000
- fun start(context: Context) {
- val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED
- if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
+ private val main = Handler(Looper.getMainLooper())
+
+ @Volatile var state = State.OFF
+ private set
+
+ /** Each connected car app holds one; the service stops only when the last lets go. */
+ private var holders = 0
+ private val waiting = mutableListOf<() -> Unit>()
+
+ /**
+ * Takes a hold on the service, starting it if it isn't running, and calls [ready] (on the
+ * main thread) once it's running, has failed, or [timeoutMs] has passed - whichever comes
+ * first. A nav app bound before this has settled has no location capability to borrow,
+ * and some (MapQuest) crash outright when their own location service is refused.
+ */
+ fun acquire(context: Context, timeoutMs: Long = 3_000, ready: () -> Unit) {
+ main.post {
+ holders++
+ if (state == State.OFF || state == State.FAILED) begin(context.applicationContext)
+ if (state != State.STARTING) {
+ ready()
+ return@post
+ }
+ var done = false
+ val once = { if (!done) { done = true; ready() } }
+ waiting += once
+ main.postDelayed({
+ if (!done) GearslipLog.w("location keep-alive: still not running after ${timeoutMs}ms, binding anyway")
+ waiting.remove(once)
+ once()
+ }, timeoutMs)
+ }
}
- fun stop(context: Context) {
- context.stopService(Intent(context, LocationKeepAlive::class.java))
+ /** Lets go of one hold; the last one stops the service. */
+ fun release(context: Context) {
+ main.post {
+ holders = (holders - 1).coerceAtLeast(0)
+ if (holders > 0) return@post
+ context.stopService(Intent(context, LocationKeepAlive::class.java))
+ state = State.OFF
+ }
+ }
+
+ private fun begin(context: Context) {
+ val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+ if (!granted) {
+ GearslipLog.w("location keep-alive: no location permission, car maps won't get a fix in the background")
+ settle(State.FAILED)
+ return
+ }
+ state = State.STARTING
+ runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
+ .onFailure {
+ GearslipLog.w("location keep-alive: start refused - ${it.message}")
+ settle(State.FAILED)
+ }
+ }
+
+ private fun settle(to: State) {
+ main.post {
+ state = to
+ val run = waiting.toList()
+ waiting.clear()
+ run.forEach { it() }
+ }
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt
new file mode 100644
index 0000000..c719952
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt
@@ -0,0 +1,47 @@
+package app.seb3thehacker.gearslip.media
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import androidx.core.graphics.scale
+
+/**
+ * The colour a piece of album art reads as, for tinting what sits around it. Not the plain
+ * average, which turns most covers brown-grey: the art is shrunk to a few hundred pixels,
+ * sorted into hue buckets weighted by how saturated and bright each pixel is, and the heaviest
+ * bucket wins. Art with no real colour in it (black and white, near-grey) gives null.
+ */
+object ArtColor {
+
+ private const val SAMPLE = 24
+ private const val BUCKETS = 24
+
+ /** Hue in degrees and saturation, 0..1; null when the art has no colour worth picking. */
+ class Tint(val hue: Float, val saturation: Float)
+
+ fun of(art: Bitmap): Tint? {
+ // A hardware bitmap can't be read pixel by pixel; copy it into memory first.
+ val readable = if (art.config == Bitmap.Config.HARDWARE) art.copy(Bitmap.Config.ARGB_8888, false) else art
+ val small = runCatching { readable?.scale(SAMPLE, SAMPLE) }.getOrNull() ?: return null
+ val weight = FloatArray(BUCKETS)
+ val hueSum = FloatArray(BUCKETS)
+ val satSum = FloatArray(BUCKETS)
+ val hsv = FloatArray(3)
+ for (y in 0 until small.height) for (x in 0 until small.width) {
+ Color.colorToHSV(small.getPixel(x, y), hsv)
+ val (h, s, v) = Triple(hsv[0], hsv[1], hsv[2])
+ // Near-grey and near-black pixels (letterbox bars among them) say nothing about the art's colour.
+ if (s < 0.1f || v < 0.08f) continue
+ val w = s * v
+ val b = (h / 360f * BUCKETS).toInt().coerceIn(0, BUCKETS - 1)
+ weight[b] += w
+ hueSum[b] += h * w
+ satSum[b] += s * w
+ }
+ if (small !== readable) small.recycle()
+ if (readable !== art) readable?.recycle()
+ val best = weight.indices.maxBy { weight[it] }
+ // A sliver of colour in an otherwise grey cover shouldn't tint the whole panel.
+ if (weight[best] < SAMPLE * SAMPLE * 0.005f) return null
+ return Tint(hueSum[best] / weight[best], satSum[best] / weight[best])
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt
index 1cb52a3..299985a 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt
@@ -4,6 +4,10 @@ import android.content.ComponentName
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadata
+import android.media.Rating
+import android.support.v4.media.session.MediaControllerCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.MediaSession
@@ -32,7 +36,8 @@ class MediaEntry(
val playable: Boolean,
)
-class CustomAction(val id: String, val name: String, val extras: Bundle?)
+/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
+class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0)
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
class QueueTrack(
@@ -60,7 +65,40 @@ class NowPlaying(
val error: String? = null,
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
+ /** The track's heart rating, for apps that take one; null when the app doesn't rate by heart. */
+ val heart: Boolean? = null,
) {
+ /** The app's own button for [words], matched on its id or name - how most apps offer like, shuffle and repeat. */
+ fun customFor(vararg words: String) = custom.firstOrNull { a ->
+ words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
+ }
+
+ /** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
+ val likeAction
+ get() = custom.firstOrNull { a ->
+ val text = "${a.id} ${a.name}"
+ listOf("like", "favorit", "favourit", "heart", "love", "thumb").any { text.contains(it, ignoreCase = true) } &&
+ listOf("dislike", "down").none { text.contains(it, ignoreCase = true) }
+ }
+ val shuffleAction get() = customFor("shuffle")
+
+ /** The app's buttons that aren't like, shuffle or repeat - "Start radio" and the like. */
+ val otherActions: List
+ get() {
+ val known = listOf(likeAction, shuffleAction, repeatAction)
+ return custom.filter { a -> known.none { it === a } }
+ }
+ val repeatAction get() = customFor("repeat", "loop")
+
+ /**
+ * Liked or not; null when the app offers no way to like at all. An app's own like button
+ * only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
+ */
+ val liked: Boolean?
+ get() = heart ?: likeAction?.name?.let { name ->
+ listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
+ }
+
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
@@ -124,8 +162,47 @@ class CarMedia(private val context: Context) {
private val _queue = MutableStateFlow>(emptyList())
val queue: StateFlow> = _queue.asStateFlow()
+ /** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
+ class Modes(
+ val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
+ val repeat: Int = PlaybackStateCompat.REPEAT_MODE_INVALID,
+ )
+
+ private val _modes = MutableStateFlow(Modes())
+ val modes: StateFlow = _modes.asStateFlow()
+
private var browser: MediaBrowser? = null
private var controller: MediaController? = null
+
+ /**
+ * The same session through the support library's controller - the framework one has no
+ * shuffle or repeat calls at all. Every app built on MediaSessionCompat or Media3 answers
+ * these; one on the bare framework API never reports a mode, and the buttons stay off.
+ */
+ private var compat: MediaControllerCompat? = null
+ private val compatCallback = object : MediaControllerCompat.Callback() {
+ override fun onSessionReady() = refreshModes()
+ override fun onShuffleModeChanged(shuffleMode: Int) = refreshModes()
+ override fun onRepeatModeChanged(repeatMode: Int) = refreshModes()
+ }
+
+ private fun attach(c: MediaController) {
+ controller = c
+ c.registerCallback(callback)
+ update(c.metadata, c.playbackState)
+ _queue.value = queueOf(c.queue)
+ compat = runCatching {
+ MediaControllerCompat(context, MediaSessionCompat.Token.fromToken(c.sessionToken)).also {
+ it.registerCallback(compatCallback, main)
+ }
+ }.onFailure { GearslipLog.w("media: no support-library view of the session: ${it.message}") }.getOrNull()
+ refreshModes()
+ }
+
+ private fun refreshModes() {
+ val c = compat
+ _modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode)
+ }
private var subscribed: String? = null
private var rootId: String? = null
private val path = ArrayDeque>() // id to title
@@ -183,11 +260,7 @@ class CarMedia(private val context: Context) {
return
}
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
- controller = session.also {
- it.registerCallback(callback)
- update(it.metadata, it.playbackState)
- _queue.value = queueOf(it.queue)
- }
+ attach(session)
_browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY
}
@@ -195,11 +268,7 @@ class CarMedia(private val context: Context) {
private fun onConnected(app: MediaApp) {
val mb = browser ?: return
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
- controller = MediaController(context, mb.sessionToken).also {
- it.registerCallback(callback)
- update(it.metadata, it.playbackState)
- _queue.value = queueOf(it.queue)
- }
+ attach(MediaController(context, mb.sessionToken))
rootId = mb.root
path.clear()
_phase.value = Phase.READY
@@ -241,9 +310,12 @@ class CarMedia(private val context: Context) {
speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0,
- custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
+ custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) },
error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
+ heart = if (controller?.ratingType == Rating.RATING_HEART) {
+ metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
+ } else null,
)
}
@@ -320,9 +392,40 @@ class CarMedia(private val context: Context) {
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
+ /** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
+ fun toggleLike() {
+ val now = _now.value
+ now.likeAction?.let { custom(it); return }
+ now.heart?.let { controller?.transportControls?.setRating(Rating.newHeartRating(!it)) }
+ }
+
+ fun toggleShuffle() {
+ _now.value.shuffleAction?.let { custom(it); return }
+ val next = if (_modes.value.shuffle == PlaybackStateCompat.SHUFFLE_MODE_NONE) {
+ PlaybackStateCompat.SHUFFLE_MODE_ALL
+ } else {
+ PlaybackStateCompat.SHUFFLE_MODE_NONE
+ }
+ compat?.transportControls?.setShuffleMode(next)
+ }
+
+ /** Off, then the whole queue, then this one track, then off again - the order every player uses. */
+ fun cycleRepeat() {
+ _now.value.repeatAction?.let { custom(it); return }
+ val next = when (_modes.value.repeat) {
+ PlaybackStateCompat.REPEAT_MODE_NONE -> PlaybackStateCompat.REPEAT_MODE_ALL
+ PlaybackStateCompat.REPEAT_MODE_ALL, PlaybackStateCompat.REPEAT_MODE_GROUP -> PlaybackStateCompat.REPEAT_MODE_ONE
+ else -> PlaybackStateCompat.REPEAT_MODE_NONE
+ }
+ compat?.transportControls?.setRepeatMode(next)
+ }
+
fun disconnect() {
connecting = null
main.removeCallbacksAndMessages(null)
+ runCatching { compat?.unregisterCallback(compatCallback) }
+ compat = null
+ _modes.value = Modes()
runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() }
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt
index 09c4f3b..1abd7d6 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto
+import app.seb3thehacker.gearslip.host.CarDescriptor
import app.seb3thehacker.gearslip.host.KnownApps
import android.content.ComponentName
import android.content.Context
@@ -19,15 +20,13 @@ class MediaApp(
/**
* Finds media apps that opted in to Android Auto.
*
- * An app is a car media app when it exports a MediaBrowserService *and* declares the
- * `com.google.android.gms.car.application` meta-data pointing at a descriptor with
- * ``. The second half is what separates Deezer from a phone-only player
- * that merely exposes a browser service to Bluetooth and Wear.
+ * An app is a car media app when it exports a MediaBrowserService *and* its car descriptor (see
+ * [CarDescriptor]) lists `media`. The descriptor is what separates Deezer from a phone-only
+ * player that merely exposes a browser service to Bluetooth and Wear - and from messengers like
+ * Signal and Molly, which declare only `notification` but keep a browser service for voice notes.
*/
object MediaCatalog {
- private const val CAR_META = "com.google.android.gms.car.application"
-
fun installed(context: Context): List {
val manager = context.packageManager
return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA)
@@ -35,7 +34,7 @@ object MediaCatalog {
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
- .filter { declaresCar(manager, it.serviceInfo) }
+ .filter { declaresMedia(manager, it.serviceInfo) }
.map {
val info = it.serviceInfo
MediaApp(
@@ -49,11 +48,10 @@ object MediaCatalog {
.toList()
}
- private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
- if (service.metaData?.containsKey(CAR_META) == true) return true
- return runCatching {
- manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA)
- .metaData?.containsKey(CAR_META) == true
- }.getOrDefault(false)
+ /** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */
+ private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
+ if (CarDescriptor.meta(manager, service.packageName, service) == null) return false
+ val uses = CarDescriptor.uses(manager, service.packageName, service)
+ return uses.isNullOrEmpty() || "media" in uses
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt
index fba7a75..66b18a3 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt
@@ -15,6 +15,9 @@ import kotlinx.coroutines.flow.update
/** How a notification can be answered: the action the app attached, and the field it fills. */
class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput)
+/** One message of a conversation. [sender] is null for the phone's owner ("You"). */
+data class ChatLine(val sender: String?, val text: String, val at: Long)
+
/**
* One notification as the car shows it. Only what the car UI needs is kept - the phone's
* Notification object stays with the listener, apart from the reply action.
@@ -24,6 +27,7 @@ data class CarNotification(
val key: String,
/** Unique per post, so an update to an already-shown notification still counts as new. */
val id: Long,
+ val packageName: String,
val appLabel: String,
val title: String,
val text: String,
@@ -32,6 +36,12 @@ data class CarNotification(
/** Null when the app offered no reply, or once the notification has left the phone. */
val reply: ReplyTarget?,
val replied: Boolean = false,
+ /** The thread's recent messages, oldest first, when the app posts it as a conversation. */
+ val lines: List = emptyList(),
+ val isGroup: Boolean = false,
+ /** The app's own "mark as read" action, fired once the driver has opened the thread. */
+ val markRead: Notification.Action? = null,
+ val read: Boolean = false,
)
/**
@@ -83,13 +93,18 @@ object CarNotifications {
/** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */
fun gone(key: String) {
- _history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } }
+ _history.update { list -> list.map { if (it.key == key) it.copy(reply = null, markRead = null) else it } }
}
fun dismissPopup(id: Long) {
_popup.update { if (it?.id == id) null else it }
}
+ /** While an app's own messages screen is up, its new messages land there, not in a popup. */
+ fun dismissPopupFrom(packageName: String) {
+ _popup.update { if (it?.packageName == packageName) null else it }
+ }
+
/** Called while the notification screen is showing: nothing is unread there. */
fun markRead() {
_unread.value = 0
@@ -102,6 +117,22 @@ object CarNotifications {
_unread.value = 0
}
+ /**
+ * Fires the app's own "mark as read", which also clears it from the phone - and with it the
+ * reply action, so this is the driver's call, not something done on opening the thread.
+ */
+ fun markThreadRead(context: Context, notification: CarNotification): Boolean {
+ val action = notification.markRead ?: return false
+ return try {
+ action.actionIntent.send(context, 0, Intent())
+ _history.update { list -> list.map { if (it.key == notification.key) it.copy(read = true, markRead = null) else it } }
+ true
+ } catch (e: PendingIntent.CanceledException) {
+ GearslipLog.w("notify: ${notification.appLabel} withdrew its mark-as-read")
+ false
+ }
+ }
+
/** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */
fun reply(context: Context, notification: CarNotification, text: String): Boolean {
val target = notification.reply ?: return false
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt
index 49bd63a..99b21ed 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
+import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
/**
@@ -41,21 +42,38 @@ class GearslipNotificationListener : NotificationListenerService() {
?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (title.isBlank() && text.isBlank()) return
+ val style = runCatching { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(n) }.getOrNull()
+ // In MessagingStyle a message with no sender is the phone owner's own.
+ val me = style?.user?.name?.toString()
+ val lines = style?.messages.orEmpty().takeLast(MAX_LINES).mapNotNull { m ->
+ val body = m.text?.toString()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ val sender = m.person?.name?.toString()?.takeIf { it.isNotBlank() && it != me }
+ ChatLine(sender, body, m.timestamp)
+ }
+
CarNotifications.post(
CarNotification(
key = sbn.key,
id = CarNotifications.nextId(),
+ packageName = sbn.packageName,
appLabel = appLabel(sbn.packageName),
- title = title,
- text = text,
+ title = style?.conversationTitle?.toString()?.takeIf { it.isNotBlank() } ?: title,
+ text = lines.lastOrNull()?.text ?: text,
postedAt = sbn.postTime,
icon = icon(sbn),
reply = replyOf(n),
+ lines = lines,
+ isGroup = style?.isGroupConversation == true,
+ markRead = markReadOf(n),
),
quiet,
)
}
+ private fun markReadOf(n: Notification): Notification.Action? =
+ (n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions)
+ .firstOrNull { it.actionIntent != null && it.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ }
+
private fun appLabel(packageName: String): String = runCatching {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName.substringAfterLast('.'))
@@ -79,6 +97,7 @@ class GearslipNotificationListener : NotificationListenerService() {
private companion object {
const val ICON_PX = 96
+ const val MAX_LINES = 8
val QUIET_CATEGORIES = setOf(
Notification.CATEGORY_TRANSPORT,
Notification.CATEGORY_PROGRESS,
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt
new file mode 100644
index 0000000..e81c353
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt
@@ -0,0 +1,39 @@
+package app.seb3thehacker.gearslip.notify
+
+import android.content.Context
+import android.content.Intent
+import app.seb3thehacker.gearslip.host.AndroidAuto
+import app.seb3thehacker.gearslip.host.CarDescriptor
+import app.seb3thehacker.gearslip.host.KnownApps
+
+/** A messaging app that has told the system it belongs in a car. */
+class MessagingApp(val packageName: String, val label: String)
+
+/**
+ * Finds messaging apps that opted in to Android Auto: those whose car descriptor lists
+ * `notification` (Signal, Molly, WhatsApp, Telegram, Google Messages). Android Auto has no
+ * screen of their own to open; it shows their conversations from the notifications they post,
+ * and so does Gearslip.
+ */
+object MessagingCatalog {
+
+ fun installed(context: Context): List {
+ val manager = context.packageManager
+ val launchable = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ return manager.queryIntentActivities(launchable, 0)
+ .asSequence()
+ .map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
+ .distinctBy { it.first }
+ .filter { (pkg, _) -> pkg != context.packageName }
+ .filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) || KnownApps.isHidden(pkg) }
+ .filter { (pkg, _) -> CarDescriptor.uses(manager, pkg)?.contains("notification") == true }
+ .map { (pkg, _) ->
+ val label = runCatching {
+ manager.getApplicationLabel(manager.getApplicationInfo(pkg, 0)).toString()
+ }.getOrDefault(pkg)
+ MessagingApp(pkg, label)
+ }
+ .sortedBy { it.label.lowercase() }
+ .toList()
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
index 7f63a6a..1d44605 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
@@ -58,7 +58,7 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
.windowInsetsPadding(WindowInsets.statusBars)
.consumeWindowInsets(WindowInsets.statusBars),
) {
- if (BuildConfig.DEBUG) DevBuildBanner()
+ if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DevBuildBanner()
Column(Modifier.weight(1f)) {
when (screen) {
diff --git a/gearslip/src/main/res/drawable/lyrics_24.xml b/gearslip/src/main/res/drawable/lyrics_24.xml
new file mode 100644
index 0000000..2dc3651
--- /dev/null
+++ b/gearslip/src/main/res/drawable/lyrics_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/gearslip/src/main/res/drawable/mic_24.xml b/gearslip/src/main/res/drawable/mic_24.xml
new file mode 100644
index 0000000..d5de5f8
--- /dev/null
+++ b/gearslip/src/main/res/drawable/mic_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c43d235..f967a0d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ okhttp = "5.4.0"
kotlinxSerializationJson = "1.11.0"
ossLicenses = "17.5.1"
webkit = "1.16.0"
+androidxMedia = "1.6.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -40,6 +41,8 @@ androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", v
# Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which
# live in the projected artifact rather than the core one.
androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" }
+# MediaControllerCompat: reads and sets shuffle and repeat, which the framework session API has no calls for.
+androidx-media = { group = "androidx.media", name = "media", version.ref = "androidxMedia" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }