feat(talk): pause + drag overlay orb

This commit is contained in:
Peter Steinberger
2025-12-30 11:37:52 +01:00
parent 2814815312
commit 9c532eac07
3 changed files with 98 additions and 18 deletions

View File

@@ -8,6 +8,9 @@ final class TalkModeController {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.controller")
private(set) var phase: TalkModePhase = .idle
private(set) var isPaused: Bool = false
func setEnabled(_ enabled: Bool) async {
self.logger.info("talk enabled=\(enabled)")
if enabled {
@@ -19,14 +22,30 @@ final class TalkModeController {
}
func updatePhase(_ phase: TalkModePhase) {
self.phase = phase
TalkOverlayController.shared.updatePhase(phase)
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: phase.rawValue) }
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
}
func updateLevel(_ level: Double) {
TalkOverlayController.shared.updateLevel(level)
}
func setPaused(_ paused: Bool) {
guard self.isPaused != paused else { return }
self.logger.info("talk paused=\(paused)")
self.isPaused = paused
TalkOverlayController.shared.updatePaused(paused)
let effectivePhase = paused ? "paused" : self.phase.rawValue
Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) }
Task { await TalkModeRuntime.shared.setPaused(paused) }
}
func togglePaused() {
self.setPaused(!self.isPaused)
}
func stopSpeaking(reason: TalkStopReason = .userTap) {
Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) }
}

View File

@@ -15,6 +15,7 @@ final class TalkOverlayController {
struct Model {
var isVisible: Bool = false
var phase: TalkModePhase = .idle
var isPaused: Bool = false
var level: Double = 0
}
@@ -73,11 +74,26 @@ final class TalkOverlayController {
self.model.phase = phase
}
func updatePaused(_ paused: Bool) {
guard self.model.isPaused != paused else { return }
self.logger.info("talk overlay paused=\(paused)")
self.model.isPaused = paused
}
func updateLevel(_ level: Double) {
guard self.model.isVisible else { return }
self.model.level = max(0, min(1, level))
}
func currentWindowOrigin() -> CGPoint? {
self.window?.frame.origin
}
func setWindowOrigin(_ origin: CGPoint) {
guard let window else { return }
window.setFrameOrigin(origin)
}
// MARK: - Private
private func ensureWindow() {

View File

@@ -4,21 +4,58 @@ struct TalkOverlayView: View {
var controller: TalkOverlayController
@State private var appState = AppStateStore.shared
@State private var hoveringWindow = false
@State private var dragStartOrigin: CGPoint?
@State private var didDrag: Bool = false
private static let orbCornerNudge: CGFloat = 12
var body: some View {
ZStack(alignment: .topTrailing) {
let isPaused = self.controller.model.isPaused
TalkOrbView(
phase: self.controller.model.phase,
level: self.controller.model.level,
accent: self.seamColor)
accent: self.seamColor,
isPaused: isPaused)
.frame(width: 96, height: 96)
.padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
.padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
.contentShape(Circle())
.onTapGesture {
TalkModeController.shared.stopSpeaking(reason: .userTap)
}
.opacity(isPaused ? 0.55 : 1)
.highPriorityGesture(
TapGesture(count: 2).onEnded {
TalkModeController.shared.stopSpeaking(reason: .userTap)
})
.highPriorityGesture(
TapGesture().onEnded {
if self.didDrag { return }
TalkModeController.shared.togglePaused()
})
.simultaneousGesture(
DragGesture(minimumDistance: 1, coordinateSpace: .global)
.onChanged { value in
if self.dragStartOrigin == nil {
self.dragStartOrigin = self.controller.currentWindowOrigin()
self.didDrag = false
TalkModeController.shared.setPaused(true)
}
if abs(value.translation.width) + abs(value.translation.height) > 2 {
self.didDrag = true
}
guard let start = self.dragStartOrigin else { return }
let origin = CGPoint(
x: start.x + value.translation.width,
y: start.y - value.translation.height)
self.controller.setWindowOrigin(origin)
}
.onEnded { _ in
self.dragStartOrigin = nil
Task { @MainActor in
try? await Task.sleep(nanoseconds: 80_000_000)
self.didDrag = false
}
})
.overlay(alignment: .topLeading) {
Button {
TalkModeController.shared.exitTalkMode()
@@ -65,24 +102,32 @@ private struct TalkOrbView: View {
let phase: TalkModePhase
let level: Double
let accent: Color
let isPaused: Bool
var body: some View {
TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
if self.isPaused {
Circle()
.fill(self.orbGradient)
.overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 1))
.shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 5)
} else {
TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
ZStack {
Circle()
.fill(self.orbGradient)
.overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1))
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
.scaleEffect(pulse * listenScale)
ZStack {
Circle()
.fill(self.orbGradient)
.overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1))
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
.scaleEffect(pulse * listenScale)
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
if phase == .thinking {
TalkOrbitArcs(time: t)
if phase == .thinking {
TalkOrbitArcs(time: t)
}
}
}
}