mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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)
|
||||||
|
|||||||
Reference in new issue
Block a user