332 lines
13 KiB
Swift
332 lines
13 KiB
Swift
import Foundation
|
|
import KeyboardShortcuts
|
|
import Carbon
|
|
import AppKit
|
|
|
|
extension KeyboardShortcuts.Name {
|
|
static let toggleMiniRecorder = Self("toggleMiniRecorder")
|
|
static let escapeRecorder = Self("escapeRecorder")
|
|
static let toggleEnhancement = Self("toggleEnhancement")
|
|
// Prompt selection shortcuts
|
|
static let selectPrompt1 = Self("selectPrompt1")
|
|
static let selectPrompt2 = Self("selectPrompt2")
|
|
static let selectPrompt3 = Self("selectPrompt3")
|
|
static let selectPrompt4 = Self("selectPrompt4")
|
|
static let selectPrompt5 = Self("selectPrompt5")
|
|
static let selectPrompt6 = Self("selectPrompt6")
|
|
static let selectPrompt7 = Self("selectPrompt7")
|
|
static let selectPrompt8 = Self("selectPrompt8")
|
|
static let selectPrompt9 = Self("selectPrompt9")
|
|
}
|
|
|
|
@MainActor
|
|
class HotkeyManager: ObservableObject {
|
|
@Published var isListening = false
|
|
@Published var isShortcutConfigured = false
|
|
@Published var isPushToTalkEnabled: Bool {
|
|
didSet {
|
|
UserDefaults.standard.set(isPushToTalkEnabled, forKey: "isPushToTalkEnabled")
|
|
if !isPushToTalkEnabled {
|
|
isRightOptionKeyPressed = false
|
|
isFnKeyPressed = false
|
|
isRightCommandKeyPressed = false
|
|
isRightShiftKeyPressed = false
|
|
keyPressStartTime = nil
|
|
}
|
|
setupKeyMonitors()
|
|
}
|
|
}
|
|
@Published var pushToTalkKey: PushToTalkKey {
|
|
didSet {
|
|
UserDefaults.standard.set(pushToTalkKey.rawValue, forKey: "pushToTalkKey")
|
|
isRightOptionKeyPressed = false
|
|
isFnKeyPressed = false
|
|
isRightCommandKeyPressed = false
|
|
isRightShiftKeyPressed = false
|
|
keyPressStartTime = nil
|
|
}
|
|
}
|
|
|
|
private var whisperState: WhisperState
|
|
private var isRightOptionKeyPressed = false
|
|
private var isFnKeyPressed = false
|
|
private var isRightCommandKeyPressed = false
|
|
private var isRightShiftKeyPressed = false
|
|
private var localKeyMonitor: Any?
|
|
private var globalKeyMonitor: Any?
|
|
private var visibilityTask: Task<Void, Never>?
|
|
private var keyPressStartTime: Date?
|
|
private let shortPressDuration: TimeInterval = 0.5 // 300ms threshold
|
|
|
|
// Add cooldown management
|
|
private var lastShortcutTriggerTime: Date?
|
|
private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown
|
|
|
|
enum PushToTalkKey: String, CaseIterable {
|
|
case rightOption = "rightOption"
|
|
case fn = "fn"
|
|
case rightCommand = "rightCommand"
|
|
case rightShift = "rightShift"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .rightOption: return "Right Option (⌥)"
|
|
case .fn: return "Fn"
|
|
case .rightCommand: return "Right Command (⌘)"
|
|
case .rightShift: return "Right Shift (⇧)"
|
|
}
|
|
}
|
|
}
|
|
|
|
init(whisperState: WhisperState) {
|
|
self.isPushToTalkEnabled = UserDefaults.standard.bool(forKey: "isPushToTalkEnabled")
|
|
self.pushToTalkKey = PushToTalkKey(rawValue: UserDefaults.standard.string(forKey: "pushToTalkKey") ?? "") ?? .rightCommand
|
|
self.whisperState = whisperState
|
|
|
|
updateShortcutStatus()
|
|
setupEnhancementShortcut()
|
|
|
|
// Start observing mini recorder visibility
|
|
setupVisibilityObserver()
|
|
}
|
|
|
|
private func setupVisibilityObserver() {
|
|
visibilityTask = Task { @MainActor in
|
|
for await isVisible in whisperState.$isMiniRecorderVisible.values {
|
|
if isVisible {
|
|
setupEscapeShortcut()
|
|
// Set Command+E shortcut when visible
|
|
KeyboardShortcuts.setShortcut(.init(.e, modifiers: .command), for: .toggleEnhancement)
|
|
setupPromptShortcuts()
|
|
} else {
|
|
removeEscapeShortcut()
|
|
// Remove Command+E shortcut when not visible
|
|
KeyboardShortcuts.setShortcut(nil, for: .toggleEnhancement)
|
|
removePromptShortcuts()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupEscapeShortcut() {
|
|
// Set ESC as the shortcut using KeyboardShortcuts native approach
|
|
KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder)
|
|
|
|
// Setup handler
|
|
KeyboardShortcuts.onKeyDown(for: .escapeRecorder) { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self = self,
|
|
await self.whisperState.isMiniRecorderVisible else { return }
|
|
SoundManager.shared.playEscSound()
|
|
await self.whisperState.dismissMiniRecorder()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeEscapeShortcut() {
|
|
KeyboardShortcuts.setShortcut(nil, for: .escapeRecorder)
|
|
}
|
|
|
|
private func setupEnhancementShortcut() {
|
|
// Only setup the handler, don't set the shortcut here
|
|
// The shortcut will be set/removed based on visibility
|
|
KeyboardShortcuts.onKeyDown(for: .toggleEnhancement) { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self = self,
|
|
await self.whisperState.isMiniRecorderVisible,
|
|
let enhancementService = await self.whisperState.getEnhancementService() else { return }
|
|
enhancementService.isEnhancementEnabled.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeEnhancementShortcut() {
|
|
KeyboardShortcuts.setShortcut(nil, for: .toggleEnhancement)
|
|
}
|
|
|
|
private func setupPromptShortcuts() {
|
|
// Set up Command+1 through Command+9 shortcuts with proper key definitions
|
|
KeyboardShortcuts.setShortcut(.init(.one, modifiers: .command), for: .selectPrompt1)
|
|
KeyboardShortcuts.setShortcut(.init(.two, modifiers: .command), for: .selectPrompt2)
|
|
KeyboardShortcuts.setShortcut(.init(.three, modifiers: .command), for: .selectPrompt3)
|
|
KeyboardShortcuts.setShortcut(.init(.four, modifiers: .command), for: .selectPrompt4)
|
|
KeyboardShortcuts.setShortcut(.init(.five, modifiers: .command), for: .selectPrompt5)
|
|
KeyboardShortcuts.setShortcut(.init(.six, modifiers: .command), for: .selectPrompt6)
|
|
KeyboardShortcuts.setShortcut(.init(.seven, modifiers: .command), for: .selectPrompt7)
|
|
KeyboardShortcuts.setShortcut(.init(.eight, modifiers: .command), for: .selectPrompt8)
|
|
KeyboardShortcuts.setShortcut(.init(.nine, modifiers: .command), for: .selectPrompt9)
|
|
|
|
// Setup handlers for each shortcut
|
|
setupPromptHandler(for: .selectPrompt1, index: 0)
|
|
setupPromptHandler(for: .selectPrompt2, index: 1)
|
|
setupPromptHandler(for: .selectPrompt3, index: 2)
|
|
setupPromptHandler(for: .selectPrompt4, index: 3)
|
|
setupPromptHandler(for: .selectPrompt5, index: 4)
|
|
setupPromptHandler(for: .selectPrompt6, index: 5)
|
|
setupPromptHandler(for: .selectPrompt7, index: 6)
|
|
setupPromptHandler(for: .selectPrompt8, index: 7)
|
|
setupPromptHandler(for: .selectPrompt9, index: 8)
|
|
}
|
|
|
|
private func setupPromptHandler(for shortcutName: KeyboardShortcuts.Name, index: Int) {
|
|
KeyboardShortcuts.onKeyDown(for: shortcutName) { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self = self,
|
|
await self.whisperState.isMiniRecorderVisible,
|
|
let enhancementService = await self.whisperState.getEnhancementService() else { return }
|
|
|
|
let prompts = enhancementService.allPrompts
|
|
if index < prompts.count {
|
|
// Enable AI enhancement if it's not already enabled
|
|
if !enhancementService.isEnhancementEnabled {
|
|
enhancementService.isEnhancementEnabled = true
|
|
}
|
|
// Switch to the selected prompt
|
|
enhancementService.setActivePrompt(prompts[index])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removePromptShortcuts() {
|
|
// Remove Command+1 through Command+9 shortcuts
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt1)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt2)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt3)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt4)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt5)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt6)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt7)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt8)
|
|
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt9)
|
|
}
|
|
|
|
func updateShortcutStatus() {
|
|
isShortcutConfigured = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
|
|
|
if isShortcutConfigured {
|
|
setupShortcutHandler()
|
|
setupKeyMonitors()
|
|
} else {
|
|
removeKeyMonitors()
|
|
}
|
|
}
|
|
|
|
private func setupShortcutHandler() {
|
|
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
|
|
Task { @MainActor in
|
|
await self?.handleShortcutTriggered()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleShortcutTriggered() async {
|
|
// Check cooldown
|
|
if let lastTrigger = lastShortcutTriggerTime,
|
|
Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval {
|
|
return // Still in cooldown period
|
|
}
|
|
|
|
// Update last trigger time
|
|
lastShortcutTriggerTime = Date()
|
|
|
|
// Handle the shortcut
|
|
await whisperState.handleToggleMiniRecorder()
|
|
}
|
|
|
|
private func removeKeyMonitors() {
|
|
if let monitor = localKeyMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
localKeyMonitor = nil
|
|
}
|
|
if let monitor = globalKeyMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
globalKeyMonitor = nil
|
|
}
|
|
}
|
|
|
|
private func setupKeyMonitors() {
|
|
guard isPushToTalkEnabled else {
|
|
removeKeyMonitors()
|
|
return
|
|
}
|
|
|
|
// Remove existing monitors first
|
|
removeKeyMonitors()
|
|
|
|
// Local monitor for when app is in foreground
|
|
localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
|
Task { @MainActor in
|
|
await self?.handlePushToTalkKey(event)
|
|
}
|
|
return event
|
|
}
|
|
|
|
// Global monitor for when app is in background
|
|
globalKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
|
Task { @MainActor in
|
|
await self?.handlePushToTalkKey(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handlePushToTalkKey(_ event: NSEvent) async {
|
|
// Only handle push-to-talk if enabled and configured
|
|
guard isPushToTalkEnabled && isShortcutConfigured else { return }
|
|
|
|
let keyState: Bool
|
|
switch pushToTalkKey {
|
|
case .rightOption:
|
|
keyState = event.modifierFlags.contains(.option) && event.keyCode == 0x3D
|
|
guard keyState != isRightOptionKeyPressed else { return }
|
|
isRightOptionKeyPressed = keyState
|
|
|
|
case .fn:
|
|
keyState = event.modifierFlags.contains(.function)
|
|
guard keyState != isFnKeyPressed else { return }
|
|
isFnKeyPressed = keyState
|
|
|
|
case .rightCommand:
|
|
keyState = event.modifierFlags.contains(.command) && event.keyCode == 0x36
|
|
guard keyState != isRightCommandKeyPressed else { return }
|
|
isRightCommandKeyPressed = keyState
|
|
|
|
case .rightShift:
|
|
keyState = event.modifierFlags.contains(.shift) && event.keyCode == 0x3C
|
|
guard keyState != isRightShiftKeyPressed else { return }
|
|
isRightShiftKeyPressed = keyState
|
|
}
|
|
|
|
if keyState {
|
|
// Key pressed down - start recording and store timestamp
|
|
if !whisperState.isMiniRecorderVisible {
|
|
keyPressStartTime = Date()
|
|
await whisperState.handleToggleMiniRecorder()
|
|
}
|
|
} else {
|
|
// Key released
|
|
if whisperState.isMiniRecorderVisible {
|
|
// Check if the key was pressed for less than the threshold
|
|
if let startTime = keyPressStartTime,
|
|
Date().timeIntervalSince(startTime) < shortPressDuration {
|
|
// Short press - don't stop recording
|
|
keyPressStartTime = nil
|
|
return
|
|
}
|
|
// Long press - stop recording
|
|
await whisperState.handleToggleMiniRecorder()
|
|
}
|
|
keyPressStartTime = nil
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
visibilityTask?.cancel()
|
|
Task { @MainActor in
|
|
removeKeyMonitors()
|
|
removeEscapeShortcut()
|
|
removeEnhancementShortcut()
|
|
}
|
|
}
|
|
}
|