mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +01:00
Final tweaks for Gearslip v0.1.0
This commit is contained in:
1 parent
e314aa8546
commit
68f9bf35a1
9 files changed
+147
-43
No files matched your search
@@ -12,6 +12,7 @@
|
|||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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_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.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
@@ -165,11 +166,14 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="location" />
|
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
|
<service
|
||||||
android:name=".audio.AudioCaptureService"
|
android:name=".audio.AudioCaptureService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="mediaProjection|microphone" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Touch on the car screen is replayed onto the phone through this service.
|
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.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
@@ -83,7 +84,13 @@ class AudioCaptureService : Service() {
|
|||||||
return START_NOT_STICKY
|
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)
|
ScreenShareGuard.lift(this)
|
||||||
projection = try {
|
projection = try {
|
||||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
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_NAV = "car_last_nav"
|
||||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
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_THEME = "car_app_theme"
|
||||||
private const val KEY_UI_THEME = "car_ui_theme"
|
private const val KEY_UI_THEME = "car_ui_theme"
|
||||||
private const val KEY_PINNED = "car_pinned_apps"
|
private const val KEY_PINNED = "car_pinned_apps"
|
||||||
@@ -57,6 +58,7 @@ object CarSettings {
|
|||||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
|
private val playerTitleFlow = MutableStateFlow(false)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
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. */
|
/** Whether the media app starts playing by itself when the car connects. */
|
||||||
val autoplay: StateFlow<Boolean> = autoplayFlow
|
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 scale: StateFlow<Float> = scaleFlow
|
||||||
val openOnConnect: StateFlow<String> = openFlow
|
val openOnConnect: StateFlow<String> = openFlow
|
||||||
val nightMode: StateFlow<NightMode> = nightFlow
|
val nightMode: StateFlow<NightMode> = nightFlow
|
||||||
@@ -182,6 +190,7 @@ object CarSettings {
|
|||||||
}.getOrDefault(AppTheme.PHONE)
|
}.getOrDefault(AppTheme.PHONE)
|
||||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
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"
|
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||||
nightFlow.value = runCatching {
|
nightFlow.value = runCatching {
|
||||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||||
@@ -229,6 +238,11 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_UI_THEME, id)
|
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) {
|
fun setAutoplay(on: Boolean) {
|
||||||
autoplayFlow.value = on
|
autoplayFlow.value = on
|
||||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ fun CarSettingsScreen() {
|
|||||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
val autoplay by CarSettings.autoplay.collectAsState()
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
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()
|
||||||
@@ -94,7 +95,7 @@ fun CarSettingsScreen() {
|
|||||||
) { CarSettings.setAppTheme(it) }
|
) { CarSettings.setAppTheme(it) }
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Button style",
|
"Theme",
|
||||||
GsThemes.builtIn.map { it.id to it.name },
|
GsThemes.builtIn.map { it.id to it.name },
|
||||||
uiTheme,
|
uiTheme,
|
||||||
) { CarSettings.setUiTheme(it) }
|
) { CarSettings.setUiTheme(it) }
|
||||||
@@ -111,6 +112,12 @@ fun CarSettingsScreen() {
|
|||||||
units,
|
units,
|
||||||
) { CarSettings.setUnits(it) }
|
) { CarSettings.setUnits(it) }
|
||||||
|
|
||||||
|
ChoiceRow(
|
||||||
|
"Player in the nav bar",
|
||||||
|
listOf(false to "Skip buttons", true to "Song title"),
|
||||||
|
playerTitle,
|
||||||
|
) { CarSettings.setPlayerTitle(it) }
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Autoplay on connect",
|
"Autoplay on connect",
|
||||||
listOf(false to "Off", true to "On"),
|
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. */
|
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||||
private const val TITLE_SHOWN_MS = 3_000L
|
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. */
|
/** "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 {
|
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||||
val full = listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||||
.ifEmpty { "Nothing playing" }
|
.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;
|
* 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
|
// 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.
|
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||||
var showTitle by remember { mutableStateOf(true) }
|
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||||
|
var announcing by remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(now.title, now.artist) {
|
LaunchedEffect(now.title, now.artist) {
|
||||||
showTitle = true
|
announcing = true
|
||||||
delay(TITLE_SHOWN_MS)
|
delay(TITLE_SHOWN_MS)
|
||||||
showTitle = false
|
announcing = false
|
||||||
}
|
}
|
||||||
|
val showTitle = keepTitle || announcing
|
||||||
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.animateContentSize()
|
.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)
|
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||||
.height(44.dp),
|
.height(44.dp),
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.withFrameMillis
|
import androidx.compose.runtime.withFrameMillis
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -55,12 +54,12 @@ import kotlinx.coroutines.delay
|
|||||||
/** How long a new message stays up on its own. */
|
/** How long a new message stays up on its own. */
|
||||||
private const val POPUP_MS = 5_000L
|
private const val POPUP_MS = 5_000L
|
||||||
|
|
||||||
/** When the plain popup goes away, for its bar; set by [PopupTimer]. */
|
/** When [this] popup goes away on its own. */
|
||||||
private val popupUntil = mutableLongStateOf(0L)
|
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
|
* Times the popup out. Each new message in the same chat starts the clock again. A voice reply
|
||||||
* starts the clock again.
|
* covers the card while it runs; if the popup's time ran out meanwhile, it goes when the reply ends.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PopupTimer() {
|
internal fun PopupTimer() {
|
||||||
@@ -68,10 +67,9 @@ internal fun PopupTimer() {
|
|||||||
val voice by VoiceReply.state.collectAsState()
|
val voice by VoiceReply.state.collectAsState()
|
||||||
val n = popup ?: return
|
val n = popup ?: return
|
||||||
val busy = voice.phase != VoiceReply.Phase.IDLE
|
val busy = voice.phase != VoiceReply.Phase.IDLE
|
||||||
LaunchedEffect(n.id, busy) {
|
LaunchedEffect(n.id, n.shownAt, busy) {
|
||||||
if (busy) return@LaunchedEffect
|
if (busy) return@LaunchedEffect
|
||||||
popupUntil.longValue = SystemClock.uptimeMillis() + POPUP_MS
|
delay((n.until() - SystemClock.uptimeMillis()).coerceAtLeast(0L))
|
||||||
delay(POPUP_MS)
|
|
||||||
CarNotifications.dismissPopup(n.id)
|
CarNotifications.dismissPopup(n.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,11 +95,11 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) {
|
|||||||
val voice by VoiceReply.state.collectAsState()
|
val voice by VoiceReply.state.collectAsState()
|
||||||
val target = voice.target
|
val target = voice.target
|
||||||
if (voice.phase != VoiceReply.Phase.IDLE && target != null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
val n = popup?.takeIf { showNew } ?: 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)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
NotificationIcon(n, size = 44.dp)
|
NotificationIcon(n, size = 44.dp)
|
||||||
Column(Modifier.weight(1f)) {
|
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
|
* 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
|
@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(
|
Surface(
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
@@ -143,6 +142,8 @@ private fun PopupCard(until: Long, span: Long, modifier: Modifier, content: @Com
|
|||||||
if (until > 0L && span > 0L) {
|
if (until > 0L && span > 0L) {
|
||||||
val left by rememberTimeLeft(until, span)
|
val left by rememberTimeLeft(until, span)
|
||||||
GsProgressBar(left, Modifier.fillMaxWidth(), height = 14.dp)
|
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. */
|
/** The share of [span] still to go before [until], updated every frame so the bar moves smoothly. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberTimeLeft(until: Long, span: Long) = produceState(fractionLeft(until, span), until, span) {
|
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) {
|
while (value > 0f) {
|
||||||
withFrameMillis { value = fractionLeft(until, span) }
|
withFrameMillis { value = fractionLeft(until, span) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ object VoiceReply {
|
|||||||
*/
|
*/
|
||||||
val until: Long = 0L,
|
val until: Long = 0L,
|
||||||
val span: 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
|
const val SEND_DELAY_MS = 5_000L
|
||||||
@@ -168,7 +170,7 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun confirm(id: Int, text: String) {
|
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.
|
// The countdown starts once the driver has heard the whole reply, not while it's read out.
|
||||||
CarAssistant.say("Sending: $text") {
|
CarAssistant.say("Sending: $text") {
|
||||||
if (id != run) return@say
|
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) {
|
private fun startCountdown(id: Int, ms: Long) {
|
||||||
val sendAt = SystemClock.uptimeMillis() + ms
|
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.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
||||||
}
|
}
|
||||||
@@ -196,7 +204,13 @@ object VoiceReply {
|
|||||||
onSpeaking = {
|
onSpeaking = {
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
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 ->
|
onResult = { text ->
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
@@ -207,7 +221,7 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
Command.CHANGE -> {
|
Command.CHANGE -> {
|
||||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
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)
|
ask(id)
|
||||||
}
|
}
|
||||||
Command.SEND -> send()
|
Command.SEND -> send()
|
||||||
@@ -273,7 +287,7 @@ object VoiceReply {
|
|||||||
GearslipLog.i("voice reply: sent to ${n.appLabel}")
|
GearslipLog.i("voice reply: sent to ${n.appLabel}")
|
||||||
CarAssistant.say("Message sent.") {}
|
CarAssistant.say("Message sent.") {}
|
||||||
CarNotifications.dismissPopup(n.id)
|
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)
|
main.postDelayed({ if (id == run) finish() }, SENT_MS)
|
||||||
} else {
|
} else {
|
||||||
_state.value = s.copy(phase = Phase.ERROR, error = "${n.appLabel} withdrew the reply. Open the app on the phone to answer.")
|
_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) {
|
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)
|
main.postDelayed({ if (id == run) finish() }, LINGER_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package app.seb3thehacker.gearslip.car.theme
|
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.background
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -21,18 +28,22 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.graphics.lerp
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.coerceAtLeast
|
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
* 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
|
* top and darker along the bottom. Below its own height the fill narrows into a smaller oval, so
|
||||||
* round at any value above zero.
|
* a countdown runs smoothly all the way to empty. The flat theme draws Material's wavy bar instead.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GsProgressBar(
|
fun GsProgressBar(
|
||||||
@@ -47,6 +58,10 @@ fun GsProgressBar(
|
|||||||
val shown = fraction.coerceIn(0f, 1f)
|
val shown = fraction.coerceIn(0f, 1f)
|
||||||
val inset = 3.dp
|
val inset = 3.dp
|
||||||
val fill = if (active) lerp(color, Color.White, 0.12f) else color
|
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(
|
BoxWithConstraints(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -57,10 +72,9 @@ fun GsProgressBar(
|
|||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
if (shown <= 0f) return@BoxWithConstraints
|
if (shown <= 0f) return@BoxWithConstraints
|
||||||
val inner = maxHeight
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width((maxWidth * shown).coerceAtLeast(inner))
|
.width(maxWidth * shown)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.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
|
* [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.
|
* 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,
|
val read: Boolean = false,
|
||||||
/** How many posts this popup stands for: new messages in one chat update it, not queue. */
|
/** How many posts this popup stands for: new messages in one chat update it, not queue. */
|
||||||
val burst: Int = 1,
|
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 &&
|
val echo = notification.key == lastReplyKey &&
|
||||||
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
|
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
|
||||||
if (quiet || echo) return
|
if (quiet || echo) return
|
||||||
// History still keeps it; only the popup is held back while driving.
|
val now = android.os.SystemClock.uptimeMillis()
|
||||||
if (CarMotion.moving) return
|
|
||||||
_popup.update { shown ->
|
_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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user