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) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
onDismiss = { viewModel.setTalkEnabled(false) },
)
}
// Always show the orb as a PTT button - pulses when active, static when inactive
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
TalkOrbOverlay(
seamColor = seamColor,
statusText = talkStatusText,
isListening = talkIsListening,
isSpeaking = talkIsSpeaking,
isActive = talkEnabled,
onTap = { viewModel.setTalkEnabled(!talkEnabled) },
)
}
val currentSheet = sheet

View File

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