Final tweaks for Gearslip v0.1.0

This commit is contained in:
Seb3thehacker committed 2026-10-01 00:34:53 -07:00
1 parent e314aa8546
commit 68f9bf35a1
9 files changed
+147 -43

No files matched your search

+6 -2
View File
@@ -12,6 +12,7 @@
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -165,11 +166,14 @@
android:exported="false"
android:foregroundServiceType="location" />
<!-- Holds the capture session that carries a media app's audio to the car. -->
<!--
Holds the capture session that carries a media app's audio to the car. It also claims
the microphone, so voice replies can hear the driver with the phone locked.
-->
<service
android:name=".audio.AudioCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
android:foregroundServiceType="mediaProjection|microphone" />
<!--
Touch on the car screen is replayed onto the phone through this service.
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
@@ -83,7 +84,13 @@ class AudioCaptureService : Service() {
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
// Android silences the microphone for apps in the background unless a running service
// claims it. Claiming it here lets voice replies hear the driver with the phone locked.
// Without the permission, claiming it would crash, so it's left off.
val micGranted = checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
(if (micGranted) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0)
startForeground(NOTIFICATION_ID, notification(), type)
ScreenShareGuard.lift(this)
projection = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
@@ -30,6 +30,7 @@ object CarSettings {
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title"
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"
@@ -57,6 +58,7 @@ object CarSettings {
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
@@ -125,6 +127,12 @@ object CarSettings {
/** Whether the media app starts playing by itself when the car connects. */
val autoplay: StateFlow<Boolean> = autoplayFlow
/**
* The nav bar's player keeps the song title up, with play/pause and the dock button, instead of
* settling to the skip buttons once a new song has announced itself.
*/
val playerTitle: StateFlow<Boolean> = playerTitleFlow
val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow
@@ -182,6 +190,7 @@ object CarSettings {
}.getOrDefault(AppTheme.PHONE)
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
@@ -229,6 +238,11 @@ object CarSettings {
AppSettings.putString(app, KEY_UI_THEME, id)
}
fun setPlayerTitle(on: Boolean) {
playerTitleFlow.value = on
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -54,6 +54,7 @@ fun CarSettingsScreen() {
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
@@ -94,7 +95,7 @@ fun CarSettingsScreen() {
) { CarSettings.setAppTheme(it) }
ChoiceRow(
"Button style",
"Theme",
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
@@ -111,6 +112,12 @@ fun CarSettingsScreen() {
units,
) { CarSettings.setUnits(it) }
ChoiceRow(
"Player in the nav bar",
listOf(false to "Skip buttons", true to "Song title"),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
@@ -668,8 +668,6 @@ 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
@@ -732,13 +730,10 @@ private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
}
}
/** "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(" - ")
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
private fun nowPlayingLabel(title: String, artist: String): String =
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;
@@ -767,20 +762,22 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
}
// 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) }
// goes and the pill settles to just the controls - unless the driver keeps the title up.
val keepTitle by CarSettings.playerTitle.collectAsState()
var announcing by remember { mutableStateOf(true) }
LaunchedEffect(now.title, now.artist) {
showTitle = true
announcing = true
delay(TITLE_SHOWN_MS)
showTitle = false
announcing = false
}
val showTitle = keepTitle || announcing
val scheme = MaterialTheme.colorScheme
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
Surface(
modifier = modifier
.animateContentSize()
.widthIn(max = 400.dp)
// With the title up, all the room the bar has, so as much of it shows as can.
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
.height(44.dp),
shape = RoundedCornerShape(14.dp),
@@ -25,7 +25,6 @@ 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.withFrameMillis
import androidx.compose.ui.Alignment
@@ -55,12 +54,12 @@ import kotlinx.coroutines.delay
/** How long a new message stays up on its own. */
private const val POPUP_MS = 5_000L
/** When the plain popup goes away, for its bar; set by [PopupTimer]. */
private val popupUntil = mutableLongStateOf(0L)
/** When [this] popup goes away on its own. */
private fun CarNotification.until() = shownAt + POPUP_MS
/**
* Times the popup out. A voice reply in progress holds it, and each new message in the same chat
* starts the clock again.
* Times the popup out. Each new message in the same chat starts the clock again. A voice reply
* covers the card while it runs; if the popup's time ran out meanwhile, it goes when the reply ends.
*/
@Composable
internal fun PopupTimer() {
@@ -68,10 +67,9 @@ internal fun PopupTimer() {
val voice by VoiceReply.state.collectAsState()
val n = popup ?: return
val busy = voice.phase != VoiceReply.Phase.IDLE
LaunchedEffect(n.id, busy) {
LaunchedEffect(n.id, n.shownAt, busy) {
if (busy) return@LaunchedEffect
popupUntil.longValue = SystemClock.uptimeMillis() + POPUP_MS
delay(POPUP_MS)
delay((n.until() - SystemClock.uptimeMillis()).coerceAtLeast(0L))
CarNotifications.dismissPopup(n.id)
}
}
@@ -97,11 +95,11 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
val voice by VoiceReply.state.collectAsState()
val target = voice.target
if (voice.phase != VoiceReply.Phase.IDLE && target != null) {
PopupCard(voice.until, voice.span, modifier) { VoiceReplyBody(voice, target) }
PopupCard(voice.until, voice.span, modifier, held = voice.held) { VoiceReplyBody(voice, target) }
return
}
val n = popup?.takeIf { showNew } ?: return
PopupCard(popupUntil.longValue, POPUP_MS, modifier) {
PopupCard(n.until(), POPUP_MS, modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
NotificationIcon(n, size = 44.dp)
Column(Modifier.weight(1f)) {
@@ -127,10 +125,11 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
/**
* The card's frame. When [until] is set, a bar along the bottom empties over [span] ms, so the
* driver sees at a glance how long is left before the card acts.
* driver sees at a glance how long is left before the card acts. A [held] countdown shows its bar
* standing still.
*/
@Composable
private fun PopupCard(until: Long, span: Long, modifier: Modifier, content: @Composable () -> Unit) {
private fun PopupCard(until: Long, span: Long, modifier: Modifier, held: Float = 0f, content: @Composable () -> Unit) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -143,6 +142,8 @@ private fun PopupCard(until: Long, span: Long, modifier: Modifier, content: @Com
if (until > 0L && span > 0L) {
val left by rememberTimeLeft(until, span)
GsProgressBar(left, Modifier.fillMaxWidth(), height = 14.dp)
} else if (held > 0f) {
GsProgressBar(held, Modifier.fillMaxWidth(), height = 14.dp)
}
}
}
@@ -151,6 +152,8 @@ private fun PopupCard(until: Long, span: Long, modifier: Modifier, content: @Com
/** The share of [span] still to go before [until], updated every frame so the bar moves smoothly. */
@Composable
private fun rememberTimeLeft(until: Long, span: Long) = produceState(fractionLeft(until, span), until, span) {
// The state outlives a change of deadline, so start from the new one rather than the old value.
value = fractionLeft(until, span)
while (value > 0f) {
withFrameMillis { value = fractionLeft(until, span) }
}
@@ -59,6 +59,8 @@ object VoiceReply {
*/
val until: Long = 0L,
val span: Long = 0L,
/** While a countdown is held, how much of its bar was left, so the bar stays put. */
val held: Float = 0f,
)
const val SEND_DELAY_MS = 5_000L
@@ -168,7 +170,7 @@ object VoiceReply {
}
private fun confirm(id: Int, text: String) {
_state.value = _state.value.copy(phase = Phase.CONFIRMING, heard = text, until = 0L, span = 0L)
_state.value = _state.value.copy(phase = Phase.CONFIRMING, heard = text, until = 0L, span = 0L, held = 0f)
// The countdown starts once the driver has heard the whole reply, not while it's read out.
CarAssistant.say("Sending: $text") {
if (id != run) return@say
@@ -178,9 +180,15 @@ object VoiceReply {
}
}
/**
* Sends in [ms]. After a hold the bar carries on from where it stopped instead of jumping back
* to full: the span is stretched so the bar still runs out exactly at [ms].
*/
private fun startCountdown(id: Int, ms: Long) {
val sendAt = SystemClock.uptimeMillis() + ms
_state.value = _state.value.copy(until = sendAt, span = ms)
val held = _state.value.held
val span = if (held > 0f) (ms / held).toLong() else ms
_state.value = _state.value.copy(until = sendAt, span = span, held = 0f)
main.removeCallbacksAndMessages(SEND_TOKEN)
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
}
@@ -196,7 +204,13 @@ object VoiceReply {
onSpeaking = {
if (id != run) return@dictate
main.removeCallbacksAndMessages(SEND_TOKEN)
_state.value = _state.value.copy(until = 0L, span = 0L)
val s = _state.value
val left = if (s.until > 0L && s.span > 0L) {
((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
} else {
s.held
}
_state.value = s.copy(until = 0L, span = 0L, held = left)
},
onResult = { text ->
if (id != run) return@dictate
@@ -207,7 +221,7 @@ object VoiceReply {
}
Command.CHANGE -> {
main.removeCallbacksAndMessages(SEND_TOKEN)
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L)
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
ask(id)
}
Command.SEND -> send()
@@ -273,7 +287,7 @@ object VoiceReply {
GearslipLog.i("voice reply: sent to ${n.appLabel}")
CarAssistant.say("Message sent.") {}
CarNotifications.dismissPopup(n.id)
_state.value = s.copy(phase = Phase.SENT, until = SystemClock.uptimeMillis() + SENT_MS, span = SENT_MS)
_state.value = s.copy(phase = Phase.SENT, until = SystemClock.uptimeMillis() + SENT_MS, span = SENT_MS, held = 0f)
main.postDelayed({ if (id == run) finish() }, SENT_MS)
} else {
_state.value = s.copy(phase = Phase.ERROR, error = "${n.appLabel} withdrew the reply. Open the app on the phone to answer.")
@@ -318,7 +332,7 @@ object VoiceReply {
}
private fun lingerThenIdle(id: Int) {
_state.value = _state.value.copy(until = SystemClock.uptimeMillis() + LINGER_MS, span = LINGER_MS)
_state.value = _state.value.copy(until = SystemClock.uptimeMillis() + LINGER_MS, span = LINGER_MS, held = 0f)
main.postDelayed({ if (id == run) finish() }, LINGER_MS)
}
@@ -1,5 +1,12 @@
package app.seb3thehacker.gearslip.car.theme
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
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.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
@@ -21,18 +28,22 @@ 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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import kotlin.math.sin
/**
* A capsule progress bar: a tinted track with a glossy fill set inside it, lighter along the
* top and darker along the bottom. The fill never gets narrower than it is tall, so its ends stay
* round at any value above zero.
* top and darker along the bottom. Below its own height the fill narrows into a smaller oval, so
* a countdown runs smoothly all the way to empty. The flat theme draws Material's wavy bar instead.
*/
@Composable
fun GsProgressBar(
@@ -47,6 +58,10 @@ fun GsProgressBar(
val shown = fraction.coerceIn(0f, 1f)
val inset = 3.dp
val fill = if (active) lerp(color, Color.White, 0.12f) else color
if (LocalGsTheme.current.id == GsThemes.Flat.id) {
WavyBar(shown, fill, lerp(scheme.surfaceContainerHighest, color, 0.12f), modifier.fillMaxWidth().height(height))
return
}
BoxWithConstraints(
modifier
.fillMaxWidth()
@@ -57,10 +72,9 @@ fun GsProgressBar(
contentAlignment = Alignment.CenterStart,
) {
if (shown <= 0f) return@BoxWithConstraints
val inner = maxHeight
Box(
Modifier
.width((maxWidth * shown).coerceAtLeast(inner))
.width(maxWidth * shown)
.fillMaxHeight()
.clip(CircleShape)
.background(
@@ -85,6 +99,45 @@ fun GsProgressBar(
}
}
/**
* Material 3's wavy progress bar, drawn here because the stable library doesn't offer it yet: the
* filled part is a sine wave that drifts along, then a gap, then a straight track and a stop dot
* at the end.
*/
@Composable
private fun WavyBar(fraction: Float, color: Color, track: Color, modifier: Modifier) {
val phase by rememberInfiniteTransition(label = "wave").animateFloat(
0f, 1f, infiniteRepeatable(tween(1_000, easing = LinearEasing), RepeatMode.Restart), label = "phase",
)
Canvas(modifier) {
val stroke = 4.dp.toPx()
val amplitude = (size.height / 2 - stroke).coerceIn(0f, 3.dp.toPx())
val wavelength = 32.dp.toPx()
val gap = 6.dp.toPx()
val mid = size.height / 2
val start = stroke / 2
val end = size.width - stroke / 2
val split = start + (end - start) * fraction
if (split > start) {
val wave = Path()
var x = start
val shift = phase * wavelength
while (x <= split) {
val y = mid + amplitude * sin(2 * Math.PI * (x - shift) / wavelength).toFloat()
if (x == start) wave.moveTo(x, y) else wave.lineTo(x, y)
x += 1f
}
drawPath(wave, color, style = Stroke(stroke, cap = StrokeCap.Round))
}
val trackStart = if (split > start) split + gap + stroke else start
if (trackStart < end) {
drawLine(track, Offset(trackStart, mid), Offset(end, mid), stroke, StrokeCap.Round)
}
drawCircle(color, stroke / 2, Offset(end, mid))
}
}
/**
* [GsProgressBar] that seeks: tapping or dragging moves it. The position is committed on release
* rather than on every pixel of a drag, because each commit is a request to the media app.
@@ -44,6 +44,8 @@ data class CarNotification(
val read: Boolean = false,
/** How many posts this popup stands for: new messages in one chat update it, not queue. */
val burst: Int = 1,
/** Uptime when it popped up, which sets when the popup goes. 0 until it's shown. */
val shownAt: Long = 0L,
)
/**
@@ -91,10 +93,13 @@ object CarNotifications {
val echo = notification.key == lastReplyKey &&
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
if (quiet || echo) return
// History still keeps it; only the popup is held back while driving.
if (CarMotion.moving) return
val now = android.os.SystemClock.uptimeMillis()
_popup.update { shown ->
if (shown?.key == notification.key) notification.copy(burst = shown.burst + 1) else notification
if (shown?.key == notification.key) {
notification.copy(burst = shown.burst + 1, shownAt = now)
} else {
notification.copy(shownAt = now)
}
}
}