feat(talk): pause + drag overlay orb
This commit is contained in:
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user