voice and media search are built in now, with a voice intro screen and a new overlay over the car screen

This commit is contained in:
Seb3thehacker committed 2026-10-12 01:31:24 -07:00
1 parent 30d94bcfb5
commit b2df84c46b
7 files changed
+205 -112

No files matched your search

@@ -4,7 +4,21 @@ import app.seb3thehacker.gearslip.car.theme.*
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.core.FastOutSlowInEasing
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.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,47 +44,66 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip * What the assistant hears and says, laid over the bottom of whatever screen is up: a dark shade
* across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen * so white words read over a bright map, and a glow along the bottom edge in the phone's own
* the driver was already looking at. * colours that breathes while listening. The shade passes touches through; only the buttons take them.
*/ */
@Composable @Composable
fun AssistantOverlay(modifier: Modifier = Modifier) { fun AssistantOverlay(modifier: Modifier = Modifier) {
val state by CarAssistant.state.collectAsState() val state by CarAssistant.state.collectAsState()
if (state.phase == CarAssistant.Phase.IDLE) return if (state.phase == CarAssistant.Phase.IDLE) return
val context = LocalContext.current val context = LocalContext.current
val listening = state.phase == CarAssistant.Phase.LISTENING
val glow = MaterialTheme.colorScheme.primary
val glow2 = MaterialTheme.colorScheme.tertiary
val breathe by rememberInfiniteTransition(label = "glow").animateFloat(
0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow",
)
val strength = when (state.phase) {
CarAssistant.Phase.LISTENING -> breathe
CarAssistant.Phase.ERROR -> 0.3f
else -> 0.6f
}
Surface( Box(
color = MaterialTheme.colorScheme.primaryContainer, modifier
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, .fillMaxWidth()
modifier = modifier.fillMaxWidth(), .drawBehind {
) { drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
Row( // A wide, flat oval of light rising from the bottom edge.
Modifier.padding(horizontal = 16.dp, vertical = 10.dp), scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
verticalAlignment = Alignment.CenterVertically, drawCircle(
horizontalArrangement = Arrangement.spacedBy(12.dp), Brush.radialGradient(
) { 0f to glow.copy(alpha = 0.9f * strength), 0.5f to glow2.copy(alpha = 0.35f * strength), 1f to Color.Transparent,
PulsingMic(state.phase == CarAssistant.Phase.LISTENING) center = Offset(size.width / 2, size.height), radius = size.width * 0.55f,
Column(Modifier.weight(1f)) { ),
Text( radius = size.width * 0.55f, center = Offset(size.width / 2, size.height),
when (state.phase) {
CarAssistant.Phase.LISTENING -> "Listening…"
CarAssistant.Phase.THINKING -> "…"
CarAssistant.Phase.SPEAKING -> state.reply
CarAssistant.Phase.ERROR -> state.reply
CarAssistant.Phase.IDLE -> ""
},
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
)
if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) {
Text(
"\"${state.heard}\"",
style = MaterialTheme.typography.bodySmall,
) )
} }
} }
.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PulsingMic(listening)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
val (small, big) = when (state.phase) {
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { "Listening…" }
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
else -> state.heard to state.reply
}
if (small.isNotBlank()) {
Text(small, color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(
big,
color = if (big == "Listening…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (state.missingPermission) { if (state.missingPermission) {
GsButton(tone = GsTone.Tonal, onClick = { GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity( context.startActivity(
@@ -102,15 +102,10 @@ object CarKeys {
/** /**
* The voice key: answers the message on screen if there is one, otherwise starts the * The voice key: answers the message on screen if there is one, otherwise starts the
* assistant when it's turned on. * assistant.
*/ */
private fun voice() = main.post { private fun voice() = main.post {
if (VoiceReply.onVoiceKey()) return@post if (!VoiceReply.onVoiceKey()) CarAssistant.start()
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) {
CarAssistant.start()
} else {
GearslipLog.i("keys: voice pressed, but the assistant is off in settings")
}
} }
/** Answers a ringing call; with nothing ringing, opens the phone screen. */ /** Answers a ringing call; with nothing ringing, opens the phone screen. */
@@ -38,10 +38,8 @@ object CarSettings {
private const val KEY_PINNED = "car_pinned_apps" private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_APP_ORDER = "car_app_order" private const val KEY_APP_ORDER = "car_app_order"
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_CAR_SENSORS = "car_sensors_enabled"
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units" private const val KEY_UNITS = "car_units"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates" private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
@@ -73,10 +71,8 @@ object CarSettings {
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet()) private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList()) private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val voiceAssistantFlow = MutableStateFlow(false)
private val systemSpeechFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false)
private val mediaSearchFlow = MutableStateFlow(false)
private val seenSafetyWarningFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false)
private val unitsFlow = MutableStateFlow(Units.AUTO) private val unitsFlow = MutableStateFlow(Units.AUTO)
@@ -172,13 +168,6 @@ object CarSettings {
*/ */
val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow
/**
* The voice assistant is experimental and off by default; a driver has to turn it on
* knowingly, from the settings screen, after reading the warning shown there. This flag
* alone does not run the assistant - [experimentalFeaturesEnabled] must also be on.
*/
val voiceAssistantEnabled: StateFlow<Boolean> = voiceAssistantFlow
/** Listen with the phone's own speech service instead of Gearslip's on-device model. */ /** Listen with the phone's own speech service instead of Gearslip's on-device model. */
val systemSpeech: StateFlow<Boolean> = systemSpeechFlow val systemSpeech: StateFlow<Boolean> = systemSpeechFlow
@@ -190,13 +179,6 @@ object CarSettings {
*/ */
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
/**
* Searching inside a media app is experimental and off by default: many apps claim to take
* searches and then ignore them or play something else. The media screen's search button
* shows only once this and [experimentalFeaturesEnabled] are both on.
*/
val mediaSearchEnabled: StateFlow<Boolean> = mediaSearchFlow
/** /**
* Whether the car screen's own "drive safely" warning has already been shown and dismissed * Whether the car screen's own "drive safely" warning has already been shown and dismissed
* once, from this same install. Separate from the phone side's own compat/safety dialogs - * once, from this same install. Separate from the phone side's own compat/safety dialogs -
@@ -231,10 +213,8 @@ object CarSettings {
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
.split(",").filter { it.isNotEmpty() } .split(",").filter { it.isNotEmpty() }
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
unitsFlow.value = runCatching { unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
@@ -304,21 +284,11 @@ object CarSettings {
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString()) AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
} }
fun setVoiceAssistantEnabled(on: Boolean) {
voiceAssistantFlow.value = on
AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString())
}
fun setCarSensorsEnabled(on: Boolean) { fun setCarSensorsEnabled(on: Boolean) {
carSensorsFlow.value = on carSensorsFlow.value = on
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
} }
fun setMediaSearchEnabled(on: Boolean) {
mediaSearchFlow.value = on
AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString())
}
fun setNightMode(mode: NightMode) { fun setNightMode(mode: NightMode) {
nightFlow.value = mode nightFlow.value = mode
AppSettings.putString(app, KEY_NIGHT, mode.name) AppSettings.putString(app, KEY_NIGHT, mode.name)
@@ -58,9 +58,8 @@ fun CarSettingsScreen() {
val theme by CarSettings.appTheme.collectAsState() val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState() val uiTheme by CarSettings.uiTheme.collectAsState()
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
val carSensors by CarSettings.carSensorsEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState()
val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState() val systemSpeech by CarSettings.systemSpeech.collectAsState()
val display by CarEnvironment.display.collectAsState() val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState()
val context = LocalContext.current val context = LocalContext.current
@@ -138,6 +137,14 @@ fun CarSettingsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
"none, so Gearslip keeps using its own.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
WebPageSetting() WebPageSetting()
ScreenFit(vehicle) ScreenFit(vehicle)
@@ -148,7 +155,7 @@ fun CarSettingsScreen() {
experimentalFeatures, experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) } ) { CarSettings.setExperimentalFeaturesEnabled(it) }
if (experimentalFeatures) { if (experimentalFeatures) {
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch) ExperimentalFeaturesList(carSensors)
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -205,33 +212,11 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
* so the list reads fine whether there's one entry or several. * so the list reads fine whether there's one entry or several.
*/ */
@Composable @Composable
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) { private fun ExperimentalFeaturesList(carSensors: Boolean) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
ExperimentalFeatureRow(
title = "Voice assistant",
description = "Push-to-talk and the car's voice button start listening: navigate, " +
"play media, call, or open an app by saying so. Can mishear you or act on the " +
"wrong command - keep your eyes on the road, and confirm anything it does " +
"before you rely on it.",
warning = true,
checked = voiceAssistant,
) { CarSettings.setVoiceAssistantEnabled(it) }
if (voiceAssistant) {
val systemSpeech by CarSettings.systemSpeech.collectAsState()
ExperimentalFeatureRow(
title = "Use the phone's speech recognition",
description = "Understands more words, like place and brand names, but listens " +
"through the phone's microphone, not the car's. On most phones this is Google's, " +
"which may send your voice to Google. GrapheneOS has none, so it stays with " +
"Gearslip's own.",
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
ExperimentalFeatureRow( ExperimentalFeatureRow(
title = "Vehicle data", title = "Vehicle data",
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
@@ -240,13 +225,6 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea
"guess, not a fact, until you've watched one change for yourself.", "guess, not a fact, until you've watched one change for yourself.",
checked = carSensors, checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) } ) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow(
title = "Media search",
description = "Adds a search button to the media screen. Some apps ignore the search " +
"or play something else.",
checked = mediaSearch,
) { CarSettings.setMediaSearchEnabled(it) }
} }
} }
@@ -258,7 +258,7 @@ fun CarUi() {
// Above the popup: a call is more urgent than any notification. // Above the popup: a call is more urgent than any notification.
VoiceHelpOverlay(Modifier.align(Alignment.Center)) VoiceHelpOverlay(Modifier.align(Alignment.Center))
CallOverlay(Modifier.align(Alignment.TopCenter)) CallOverlay(Modifier.align(Alignment.TopCenter))
AssistantOverlay(Modifier.align(Alignment.TopCenter)) AssistantOverlay(Modifier.align(Alignment.BottomCenter))
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
// Bottom corner, not top: a screen's own header almost always puts // Bottom corner, not top: a screen's own header almost always puts
// something in the top-right (settings, search, a Liked Songs // something in the top-right (settings, search, a Liked Songs
@@ -470,12 +470,8 @@ private fun CarNavBar(navigator: CarNavigator) {
NavHomeButton(navigator, entries, running) NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() Spacer(Modifier.width(6.dp))
val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState() NavAssistantIcon()
if (experimentalFeatures && voiceEnabled) {
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
}
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also // 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. // open stays in its pinned spot, marked with a dot, instead of showing up twice.
@@ -90,7 +90,6 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val search by media.search.collectAsStateWithLifecycle() val search by media.search.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -179,7 +178,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
if (searchAllowed && (now.canSearch || media.canSearchLibrary)) { if (now.canSearch || media.canSearchLibrary) {
GsIconButton( GsIconButton(
Icons.Filled.Search, Icons.Filled.Search,
"Search ${app.label}", "Search ${app.label}",
@@ -68,6 +68,14 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.foundation.layout.wrapContentHeight
import app.seb3thehacker.gearslip.speech.SpeechModels
import app.seb3thehacker.gearslip.stats.UsageStats import app.seb3thehacker.gearslip.stats.UsageStats
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.cos import kotlin.math.cos
@@ -115,7 +123,7 @@ private fun introColors(): IntroColors {
) )
} }
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, GRAPHENE, THANKS, DRIVE } private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, GRAPHENE, THANKS, DRIVE }
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f) private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
@@ -156,6 +164,7 @@ fun IntroScreen(onDone: () -> Unit) {
"If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) } "If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) }
IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.", IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.",
"Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) } "Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) }
IntroStep.VOICE -> VoiceStep(c, ::cq, ::cqSp, step)
IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.", IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.",
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " + "On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " +
"turned it on for Gearslip, turn it off to get full protection back.", step, "turned it on for Gearslip, turn it off to get full protection back.", step,
@@ -234,7 +243,7 @@ private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit
) )
} }
Ticker(c.primary, c.onPrimary, "WIRELESS ✦ KNOB SUPPORT ✦ FASTER VIDEO ✦ AUTO UPDATES ✦ ", cq, cqSp, reverse = false, tilt = -4f, Ticker(c.primary, c.onPrimary, "WIRELESS ✦ KNOB SUPPORT ✦ OFFLINE VOICE ✦ FASTER VIDEO ✦ AUTO UPDATES ✦ ", cq, cqSp, reverse = false, tilt = -4f,
key = key, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = height * 0.15f)) key = key, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = height * 0.15f))
Confetti(c, key, Modifier.fillMaxSize()) Confetti(c, key, Modifier.fillMaxSize())
} }
@@ -320,6 +329,7 @@ private fun BadgeStep(
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color, c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
title: String, body: String, key: Any, title: String, body: String, key: Any,
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false, action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
extra: (@Composable () -> Unit)? = null,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
) { ) {
val spin by rememberInfiniteTransition(label = "spin").animateFloat( val spin by rememberInfiniteTransition(label = "spin").animateFloat(
@@ -345,6 +355,7 @@ private fun BadgeStep(
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)), style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)), modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
) )
extra?.invoke()
if (action != null) { if (action != null) {
val (label, onClick) = action val (label, onClick) = action
Spacer(Modifier.height(if (outlined) cq(7f) else cq(9f))) Spacer(Modifier.height(if (outlined) cq(7f) else cq(9f)))
@@ -366,6 +377,90 @@ private fun BadgeStep(
} }
} }
/**
* Voice: a few things to say, drifting past, and a button that fetches both speech models so the
* assistant works the first time someone tries it in the car.
*/
@Composable
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
remember { SpeechModels.init(context) }
val needed = listOf(SpeechModels.Model.KROKO, SpeechModels.Model.PARAKEET_LARGE)
val states = needed.map { SpeechModels.state(it).collectAsState().value }
val ready = states.all { it == SpeechModels.State.Ready }
val label = when {
states.any { it is SpeechModels.State.Downloading } ->
"Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%"
states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again"
else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB"
}
BadgeStep(
c, cq, cqSp, c.primaryC, "Just say it.",
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
key,
action = if (ready) null else label to { needed.forEach { SpeechModels.download(context, it) } },
outlined = true,
extra = { CommandDrift(c, cq, cqSp, key) },
) { MicIcon(c.onPrimaryC) }
}
private fun percentOf(state: SpeechModels.State) = when (state) {
SpeechModels.State.Ready -> 100
is SpeechModels.State.Downloading -> state.percent
else -> 0
}
private val SAMPLE_COMMANDS = listOf(
"Navigate to Mission Viejo", "Play Daft Punk", "What's playing?", "Next song",
"Call Mom", "Open Organic Maps", "Pause", "Help",
)
/** Example commands rising slowly through a window that fades at both edges. */
@Composable
private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
val scroll by rememberInfiniteTransition(label = "drift").animateFloat(
0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift",
)
Box(
Modifier
.padding(top = cq(6f))
.fillMaxWidth()
.height(cq(34f))
.up(entrance(key, 500, 700), cq(7f))
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent),
blendMode = BlendMode.DstIn,
)
}
.clipToBounds(),
) {
Column(
Modifier
.wrapContentHeight(Alignment.Top, unbounded = true)
// The list is there twice; rising by half loops cleanly.
.graphicsLayer { translationY = -size.height / 2f * scroll },
verticalArrangement = Arrangement.spacedBy(cq(2.4f)),
) {
repeat(2) {
SAMPLE_COMMANDS.forEach { say ->
Text(
"\u201c$say\u201d", color = c.onSecondaryC,
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold),
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(c.secondaryC)
.padding(horizontal = cq(4.4f), vertical = cq(2f)),
)
}
}
}
}
}
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */ /** Material's scalloped "cookie": a circle whose radius waves around its edge. */
@Composable @Composable
private fun Cookie(color: Color, modifier: Modifier) { private fun Cookie(color: Color, modifier: Modifier) {
@@ -442,6 +537,33 @@ private fun DialIcon(color: Color) {
} }
} }
@Composable
private fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i ->
t.animateFloat(
0.25f, 1f,
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
label = "mic$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
val stroke = Stroke(4f, cap = StrokeCap.Round)
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
// Sound waves either side, pulsing as if someone's talking.
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
val r = if (i == 0) 15f else 21f
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
}
}
}
}
@Composable @Composable
private fun ShieldIcon(color: Color, tick: Color) { private fun ShieldIcon(color: Color, tick: Color) {
val p = entrance(IntroStep.GRAPHENE, 800, 500) val p = entrance(IntroStep.GRAPHENE, 800, 500)