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 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 { func setEnabled(_ enabled: Bool) async {
self.logger.info("talk enabled=\(enabled)") self.logger.info("talk enabled=\(enabled)")
if enabled { if enabled {
@@ -19,14 +22,30 @@ final class TalkModeController {
} }
func updatePhase(_ phase: TalkModePhase) { func updatePhase(_ phase: TalkModePhase) {
self.phase = phase
TalkOverlayController.shared.updatePhase(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) { func updateLevel(_ level: Double) {
TalkOverlayController.shared.updateLevel(level) 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) { func stopSpeaking(reason: TalkStopReason = .userTap) {
Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) }
} }

View File

@@ -15,6 +15,7 @@ final class TalkOverlayController {
struct Model { struct Model {
var isVisible: Bool = false var isVisible: Bool = false
var phase: TalkModePhase = .idle var phase: TalkModePhase = .idle
var isPaused: Bool = false
var level: Double = 0 var level: Double = 0
} }
@@ -73,11 +74,26 @@ final class TalkOverlayController {
self.model.phase = phase 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) { func updateLevel(_ level: Double) {
guard self.model.isVisible else { return } guard self.model.isVisible else { return }
self.model.level = max(0, min(1, level)) 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 // MARK: - Private
private func ensureWindow() { private func ensureWindow() {

View File

@@ -4,21 +4,58 @@ struct TalkOverlayView: View {
var controller: TalkOverlayController var controller: TalkOverlayController
@State private var appState = AppStateStore.shared @State private var appState = AppStateStore.shared
@State private var hoveringWindow = false @State private var hoveringWindow = false
@State private var dragStartOrigin: CGPoint?
@State private var didDrag: Bool = false
private static let orbCornerNudge: CGFloat = 12 private static let orbCornerNudge: CGFloat = 12
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
let isPaused = self.controller.model.isPaused
TalkOrbView( TalkOrbView(
phase: self.controller.model.phase, phase: self.controller.model.phase,
level: self.controller.model.level, level: self.controller.model.level,
accent: self.seamColor) accent: self.seamColor,
isPaused: isPaused)
.frame(width: 96, height: 96) .frame(width: 96, height: 96)
.padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) .padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
.padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) .padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
.contentShape(Circle()) .contentShape(Circle())
.onTapGesture { .opacity(isPaused ? 0.55 : 1)
.highPriorityGesture(
TapGesture(count: 2).onEnded {
TalkModeController.shared.stopSpeaking(reason: .userTap) 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) { .overlay(alignment: .topLeading) {
Button { Button {
TalkModeController.shared.exitTalkMode() TalkModeController.shared.exitTalkMode()
@@ -65,8 +102,15 @@ private struct TalkOrbView: View {
let phase: TalkModePhase let phase: TalkModePhase
let level: Double let level: Double
let accent: Color let accent: Color
let isPaused: Bool
var body: some View { var body: some View {
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 TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate let t = context.date.timeIntervalSinceReferenceDate
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
@@ -87,6 +131,7 @@ private struct TalkOrbView: View {
} }
} }
} }
}
private var orbGradient: RadialGradient { private var orbGradient: RadialGradient {
RadialGradient( RadialGradient(