Messaging screen, units setting, turn pill, messaging app support

This commit is contained in:
Seb3thehacker committed 2026-09-30 13:03:55 -07:00
1 parent ae7c3ec308
commit 89b8157eb9
46 files changed
+2691 -757

No files matched your search

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug-only override of the main icon foreground, to go with the yellow debug background:
a charcoal gear rather than light grey, and a yellow centre hole to match the background
instead of the release icon's near-black. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="66.80"
android:startY="48.70"
android:endX="81.61"
android:endY="47.71">
<item android:offset="0" android:color="#00E5484D"/>
<item android:offset="1" android:color="#E5484D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#2E3033"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#E5484D"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#e6b400"/>
</vector>
+1 -1
View File
@@ -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">
@@ -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)
}
@@ -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)
}
@@ -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") }
}
}
}
@@ -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),
@@ -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)
}
}
}
@@ -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(),
@@ -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<Entry>? = 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<List<Entry>?>(null)
val state: StateFlow<List<Entry>?> = flow.asStateFlow()
val entries: List<Entry>? 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<Entry> {
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(
@@ -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<String?>(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) }
@@ -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
@@ -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<AppTheme> = 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<String> = uiThemeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(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<Units> = 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<Set<String>> = 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())
@@ -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 <T> ChoiceRow(title: String, options: List<Pair<T, String>>, 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)
}
}
@@ -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<androidx.compose.ui.graphics.ImageBitmap?>(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<String, RunningSlot>,
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<Long> = 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) {
@@ -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>(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<String, () -> 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()
}
@@ -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,
)
}
@@ -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<Entry> = 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<String, RunningSlot> {
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<String, RunningSlot>) {
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) }
}
}
}
}
@@ -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<CarNotification>, 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<CarNotification>, 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) })
}
}
}
@@ -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),
@@ -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)
}
}
@@ -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<ImageBitmap?>(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<ArtColor.Tint?>(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
}
@@ -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()) {
@@ -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(
@@ -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.
@@ -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<Float?>(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)
}
}
@@ -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}" +
"&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
@@ -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() }
}
}
@@ -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<Float?>(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),
)
}
}
@@ -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 }
@@ -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
@@ -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 `<uses name="..."/>` 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<String>? {
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()
}
}
@@ -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 ""
@@ -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() }
}
}
}
}
@@ -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])
}
}
@@ -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<CustomAction>
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<List<QueueTrack>>(emptyList())
val queue: StateFlow<List<QueueTrack>> = _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> = _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<Pair<String, String>>() // 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() }
@@ -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
* `<uses name="media"/>`. 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<MediaApp> {
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
}
}
@@ -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<ChatLine> = 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
@@ -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,
@@ -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<MessagingApp> {
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()
}
}
@@ -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) {
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M14,9c0,-2.04,1.24,-3.79,3,-4.57V4c0,-1.1,-0.9,-2,-2,-2H4C2.9,2,2.01,2.9,2.01,4L2,22l4,-4h9c1.1,0,2,-0.9,2,-2v-2.42C15.24,12.8,14,11.05,14,9ZM10,14H6v-2h4V14ZM13,11H6V9h7V11ZM13,8H6V6h7V8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20,6.18C19.69,6.07,19.35,6,19,6c-1.66,0,-3,1.34,-3,3c0,1.66,1.34,3,3,3s3,-1.34,3,-3V3h2V1h-4V6.18Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,14c1.66,0,2.99,-1.34,2.99,-3L15,5c0,-1.66,-1.34,-3,-3,-3S9,3.34,9,5v6C9,12.66,10.34,14,12,14ZM17.3,11c0,3,-2.54,5.1,-5.3,5.1S6.7,14,6.7,11H5c0,3.41,2.72,6.23,6,6.72V21h2v-3.28c3.28,-0.48,6,-3.3,6,-6.72H17.3Z"/>
</vector>