mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Messaging screen, units setting, turn pill, messaging app support
This commit is contained in:
1 parent
ae7c3ec308
commit
89b8157eb9
46 files changed
+2691
-757
No files matched your search
@@ -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}" +
|
||||
"¤t=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
|
||||
|
||||
+21
-2
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user