feat(ui): add centered talk orb
This commit is contained in:
@@ -14,6 +14,10 @@
|
|||||||
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
|
||||||
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
- macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.
|
||||||
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
||||||
|
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
||||||
|
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
||||||
|
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
||||||
|
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
||||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||||
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
|
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||||
|
|
||||||
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF9EE0FF
|
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF7FB8D4
|
||||||
|
|
||||||
private const val a2uiReadyCheckJS: String =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
val isForeground by viewModel.isForeground.collectAsState()
|
val isForeground by viewModel.isForeground.collectAsState()
|
||||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||||
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
||||||
|
val talkStatusText by viewModel.talkStatusText.collectAsState()
|
||||||
|
val talkIsListening by viewModel.talkIsListening.collectAsState()
|
||||||
|
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
|
||||||
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
||||||
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
||||||
val audioPermissionLauncher =
|
val audioPermissionLauncher =
|
||||||
@@ -267,6 +270,17 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (talkEnabled) {
|
||||||
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
|
TalkOrbOverlay(
|
||||||
|
seamColor = seamColor,
|
||||||
|
statusText = talkStatusText,
|
||||||
|
isListening = talkIsListening,
|
||||||
|
isSpeaking = talkIsSpeaking,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val currentSheet = sheet
|
val currentSheet = sheet
|
||||||
if (currentSheet != null) {
|
if (currentSheet != null) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
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.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TalkOrbOverlay(
|
||||||
|
seamColor: Color,
|
||||||
|
statusText: String,
|
||||||
|
isListening: Boolean,
|
||||||
|
isSpeaking: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "talk-orb")
|
||||||
|
val t by
|
||||||
|
transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "pulse",
|
||||||
|
)
|
||||||
|
|
||||||
|
val trimmed = statusText.trim()
|
||||||
|
val showStatus = trimmed.isNotEmpty() && trimmed != "Off"
|
||||||
|
val phase =
|
||||||
|
when {
|
||||||
|
isSpeaking -> "Speaking"
|
||||||
|
isListening -> "Listening"
|
||||||
|
else -> "Thinking"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(modifier = Modifier.size(240.dp)) {
|
||||||
|
val center = this.center
|
||||||
|
val baseRadius = size.minDimension * 0.27f
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
center = center,
|
||||||
|
radius = baseRadius * 1.35f,
|
||||||
|
),
|
||||||
|
radius = baseRadius,
|
||||||
|
center = center,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawCircle(
|
||||||
|
color = seamColor.copy(alpha = 0.34f),
|
||||||
|
radius = baseRadius,
|
||||||
|
center = center,
|
||||||
|
style = Stroke(width = 1.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showStatus) {
|
||||||
|
Surface(
|
||||||
|
color = Color.Black.copy(alpha = 0.40f),
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = trimmed,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
color = Color.White.copy(alpha = 0.92f),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = phase,
|
||||||
|
color = Color.White.copy(alpha = 0.80f),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ final class NodeAppModel {
|
|||||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
|
private static let defaultSeamColor = Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
|
||||||
|
|
||||||
private static func color(fromHex raw: String?) -> Color? {
|
private static func color(fromHex raw: String?) -> Color? {
|
||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ private struct CanvasContent: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .center) {
|
||||||
|
if self.appModel.talkMode.isEnabled {
|
||||||
|
TalkOrbOverlay()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
StatusPill(
|
StatusPill(
|
||||||
bridge: self.bridgeStatus,
|
bridge: self.bridgeStatus,
|
||||||
|
|||||||
74
apps/ios/Sources/Voice/TalkOrbOverlay.swift
Normal file
74
apps/ios/Sources/Voice/TalkOrbOverlay.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TalkOrbOverlay: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
|
@State private var pulse: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let seam = self.appModel.seamColor
|
||||||
|
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.26), lineWidth: 2)
|
||||||
|
.frame(width: 220, height: 220)
|
||||||
|
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||||
|
.opacity(self.pulse ? 0.0 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.18), lineWidth: 2)
|
||||||
|
.frame(width: 220, height: 220)
|
||||||
|
.scaleEffect(self.pulse ? 1.45 : 1.02)
|
||||||
|
.opacity(self.pulse ? 0.0 : 0.9)
|
||||||
|
.animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
seam.opacity(0.95),
|
||||||
|
seam.opacity(0.40),
|
||||||
|
Color.black.opacity(0.55),
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 1,
|
||||||
|
endRadius: 92))
|
||||||
|
.frame(width: 136, height: 136)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(seam.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0)
|
||||||
|
.shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10)
|
||||||
|
}
|
||||||
|
.contentShape(Circle())
|
||||||
|
.onTapGesture {
|
||||||
|
self.appModel.talkMode.userTappedOrb()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.isEmpty, status != "Off" {
|
||||||
|
Text(status)
|
||||||
|
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.92))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.black.opacity(0.40))
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.onAppear {
|
||||||
|
self.pulse = true
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("Talk Mode \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import SwiftUI
|
|||||||
@Observable
|
@Observable
|
||||||
final class TalkOverlayController {
|
final class TalkOverlayController {
|
||||||
static let shared = TalkOverlayController()
|
static let shared = TalkOverlayController()
|
||||||
static let overlaySize: CGFloat = 220
|
static let overlaySize: CGFloat = 260
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
|
||||||
|
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ struct TalkOverlayView: View {
|
|||||||
.frame(width: 18, height: 18)
|
.frame(width: 18, height: 18)
|
||||||
.background(Color.black.opacity(0.4))
|
.background(Color.black.opacity(0.4))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contentShape(Circle())
|
|
||||||
.offset(x: -10, y: -10)
|
|
||||||
.opacity(self.hoveringWindow ? 1 : 0)
|
|
||||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
|
||||||
.allowsHitTesting(self.hoveringWindow)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.offset(x: -7, y: -7)
|
||||||
|
.opacity(self.hoveringWindow ? 1 : 0)
|
||||||
|
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||||
|
.allowsHitTesting(self.hoveringWindow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center)
|
.frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onHover { self.hoveringWindow = $0 }
|
.onHover { self.hoveringWindow = $0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
|
private static let defaultSeamColor = Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
|
||||||
|
|
||||||
private var seamColor: Color {
|
private var seamColor: Color {
|
||||||
Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
|
Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
|
||||||
|
|||||||
Reference in New Issue
Block a user