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