Android: orb as always-visible PTT button

- Orb is now always visible (not just when talk mode is on)
- Tap to toggle talk mode on/off
- When inactive: static, dimmed, shows 'Tap to talk'
- When active: pulsing animation with status text
- Haptic feedback on tap
This commit is contained in:
yishai 2026-01-28 21:34:43 +02:00
parent 32cf8c3290
commit 69ba01468c
2 changed files with 44 additions and 33 deletions

View File

@ -264,16 +264,16 @@ fun RootScreen(viewModel: MainViewModel) {
} }
} }
if (talkEnabled) { // Always show the orb as a PTT button - pulses when active, static when inactive
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay( TalkOrbOverlay(
seamColor = seamColor, seamColor = seamColor,
statusText = talkStatusText, statusText = talkStatusText,
isListening = talkIsListening, isListening = talkIsListening,
isSpeaking = talkIsSpeaking, isSpeaking = talkIsSpeaking,
onDismiss = { viewModel.setTalkEnabled(false) }, isActive = talkEnabled,
) onTap = { viewModel.setTalkEnabled(!talkEnabled) },
} )
} }
val currentSheet = sheet val currentSheet = sheet

View File

@ -37,12 +37,13 @@ fun TalkOrbOverlay(
statusText: String, statusText: String,
isListening: Boolean, isListening: Boolean,
isSpeaking: Boolean, isSpeaking: Boolean,
isActive: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDismiss: (() -> Unit)? = null, onTap: (() -> Unit)? = null,
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val transition = rememberInfiniteTransition(label = "talk-orb") val transition = rememberInfiniteTransition(label = "talk-orb")
val t by val animatedT by
transition.animateFloat( transition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
@ -53,15 +54,22 @@ fun TalkOrbOverlay(
), ),
label = "pulse", label = "pulse",
) )
// Only pulse when active, otherwise static
val t = if (isActive) animatedT else 0f
val trimmed = statusText.trim() val trimmed = statusText.trim()
val showStatus = trimmed.isNotEmpty() && trimmed != "Off" val showStatus = isActive && trimmed.isNotEmpty() && trimmed != "Off"
val phase = val phase =
when { when {
!isActive -> "Tap to talk"
isSpeaking -> "Speaking" isSpeaking -> "Speaking"
isListening -> "Listening" isListening -> "Listening"
else -> "Thinking" else -> "Thinking"
} }
// Dim the orb when inactive
val orbAlpha = if (isActive) 1f else 0.5f
Column( Column(
modifier = modifier.padding(24.dp), modifier = modifier.padding(24.dp),
@ -75,10 +83,10 @@ fun TalkOrbOverlay(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, // No ripple - the orb itself is the feedback indication = null, // No ripple - the orb itself is the feedback
enabled = onDismiss != null, enabled = onTap != null,
onClick = { onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDismiss?.invoke() onTap?.invoke()
} }
) )
) { ) {
@ -87,30 +95,33 @@ fun TalkOrbOverlay(
val ring1 = 1.05f + (t * 0.25f) val ring1 = 1.05f + (t * 0.25f)
val ring2 = 1.20f + (t * 0.55f) val ring2 = 1.20f + (t * 0.55f)
val ringAlpha1 = (1f - t) * 0.34f val ringAlpha1 = (1f - t) * 0.34f * orbAlpha
val ringAlpha2 = (1f - t) * 0.22f val ringAlpha2 = (1f - t) * 0.22f * orbAlpha
drawCircle( // Only draw pulsing rings when active
color = seamColor.copy(alpha = ringAlpha1), if (isActive) {
radius = baseRadius * ring1, drawCircle(
center = center, color = seamColor.copy(alpha = ringAlpha1),
style = Stroke(width = 3.dp.toPx()), radius = baseRadius * ring1,
) center = center,
drawCircle( style = Stroke(width = 3.dp.toPx()),
color = seamColor.copy(alpha = ringAlpha2), )
radius = baseRadius * ring2, drawCircle(
center = center, color = seamColor.copy(alpha = ringAlpha2),
style = Stroke(width = 3.dp.toPx()), radius = baseRadius * ring2,
) center = center,
style = Stroke(width = 3.dp.toPx()),
)
}
drawCircle( drawCircle(
brush = brush =
Brush.radialGradient( Brush.radialGradient(
colors = colors =
listOf( listOf(
seamColor.copy(alpha = 0.92f), seamColor.copy(alpha = 0.92f * orbAlpha),
seamColor.copy(alpha = 0.40f), seamColor.copy(alpha = 0.40f * orbAlpha),
Color.Black.copy(alpha = 0.56f), Color.Black.copy(alpha = 0.56f * orbAlpha),
), ),
center = center, center = center,
radius = baseRadius * 1.35f, radius = baseRadius * 1.35f,
@ -120,7 +131,7 @@ fun TalkOrbOverlay(
) )
drawCircle( drawCircle(
color = seamColor.copy(alpha = 0.34f), color = seamColor.copy(alpha = 0.34f * orbAlpha),
radius = baseRadius, radius = baseRadius,
center = center, center = center,
style = Stroke(width = 1.dp.toPx()), style = Stroke(width = 1.dp.toPx()),