Lower Level push-to-talk key detection
This commit is contained in:
parent
854af2a467
commit
f02344e5d6
@ -26,41 +26,22 @@ class HotkeyManager: ObservableObject {
|
||||
@Published var isPushToTalkEnabled: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isPushToTalkEnabled, forKey: "isPushToTalkEnabled")
|
||||
if !isPushToTalkEnabled {
|
||||
isRightOptionKeyPressed = false
|
||||
isFnKeyPressed = false
|
||||
isRightCommandKeyPressed = false
|
||||
isRightShiftKeyPressed = false
|
||||
keyPressStartTime = nil
|
||||
}
|
||||
setupKeyMonitors()
|
||||
resetKeyStates()
|
||||
setupKeyMonitor()
|
||||
}
|
||||
}
|
||||
@Published var pushToTalkKey: PushToTalkKey {
|
||||
didSet {
|
||||
UserDefaults.standard.set(pushToTalkKey.rawValue, forKey: "pushToTalkKey")
|
||||
isRightOptionKeyPressed = false
|
||||
isFnKeyPressed = false
|
||||
isRightCommandKeyPressed = false
|
||||
isRightShiftKeyPressed = false
|
||||
keyPressStartTime = nil
|
||||
resetKeyStates()
|
||||
}
|
||||
}
|
||||
|
||||
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 currentKeyState = false
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
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"
|
||||
@ -76,6 +57,24 @@ class HotkeyManager: ObservableObject {
|
||||
case .rightShift: return "Right Shift (⇧)"
|
||||
}
|
||||
}
|
||||
|
||||
var keyCode: CGKeyCode {
|
||||
switch self {
|
||||
case .rightOption: return 0x3D
|
||||
case .fn: return 0x3F
|
||||
case .rightCommand: return 0x36
|
||||
case .rightShift: return 0x3C
|
||||
}
|
||||
}
|
||||
|
||||
var flags: CGEventFlags {
|
||||
switch self {
|
||||
case .rightOption: return .maskAlternate
|
||||
case .fn: return .maskSecondaryFn
|
||||
case .rightCommand: return .maskCommand
|
||||
case .rightShift: return .maskShift
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(whisperState: WhisperState) {
|
||||
@ -85,34 +84,101 @@ class HotkeyManager: ObservableObject {
|
||||
|
||||
updateShortcutStatus()
|
||||
setupEnhancementShortcut()
|
||||
|
||||
// Start observing mini recorder visibility
|
||||
setupVisibilityObserver()
|
||||
}
|
||||
|
||||
private func resetKeyStates() {
|
||||
currentKeyState = false
|
||||
}
|
||||
|
||||
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)
|
||||
removeEnhancementShortcut()
|
||||
removePromptShortcuts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupEscapeShortcut() {
|
||||
// Set ESC as the shortcut using KeyboardShortcuts native approach
|
||||
KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder)
|
||||
private func setupKeyMonitor() {
|
||||
removeKeyMonitor()
|
||||
|
||||
// Setup handler
|
||||
guard isPushToTalkEnabled else { return }
|
||||
guard AXIsProcessTrusted() else { return }
|
||||
|
||||
let eventMask = (1 << CGEventType.flagsChanged.rawValue)
|
||||
|
||||
guard let tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(eventMask),
|
||||
callback: { proxy, type, event, refcon in
|
||||
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon!).takeUnretainedValue()
|
||||
|
||||
if type == .flagsChanged {
|
||||
Task { @MainActor in
|
||||
await manager.handleKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
return Unmanaged.passRetained(event)
|
||||
},
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
||||
) else { return }
|
||||
|
||||
self.eventTap = tap
|
||||
self.runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
|
||||
if let runLoopSource = self.runLoopSource {
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeKeyMonitor() {
|
||||
if let tap = eventTap {
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
if let runLoopSource = self.runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
}
|
||||
self.eventTap = nil
|
||||
self.runLoopSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ event: CGEvent) async {
|
||||
let flags = event.flags
|
||||
let keycode = event.getIntegerValueField(.keyboardEventKeycode)
|
||||
|
||||
let isKeyPressed = flags.contains(pushToTalkKey.flags)
|
||||
let isTargetKey = pushToTalkKey == .fn ? true : keycode == pushToTalkKey.keyCode
|
||||
|
||||
guard isTargetKey else { return }
|
||||
guard isKeyPressed != currentKeyState else { return }
|
||||
|
||||
currentKeyState = isKeyPressed
|
||||
|
||||
if isKeyPressed {
|
||||
if !whisperState.isMiniRecorderVisible {
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
} else {
|
||||
if whisperState.isMiniRecorderVisible {
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupEscapeShortcut() {
|
||||
KeyboardShortcuts.setShortcut(.init(.escape), for: .escapeRecorder)
|
||||
KeyboardShortcuts.onKeyDown(for: .escapeRecorder) { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
@ -128,8 +194,6 @@ class HotkeyManager: ObservableObject {
|
||||
}
|
||||
|
||||
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,
|
||||
@ -140,10 +204,6 @@ class HotkeyManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ -201,129 +261,32 @@ class HotkeyManager: ObservableObject {
|
||||
KeyboardShortcuts.setShortcut(nil, for: .selectPrompt9)
|
||||
}
|
||||
|
||||
private func removeEnhancementShortcut() {
|
||||
KeyboardShortcuts.setShortcut(nil, for: .toggleEnhancement)
|
||||
}
|
||||
|
||||
func updateShortcutStatus() {
|
||||
isShortcutConfigured = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
|
||||
if isShortcutConfigured {
|
||||
setupShortcutHandler()
|
||||
setupKeyMonitors()
|
||||
setupKeyMonitor()
|
||||
} else {
|
||||
removeKeyMonitors()
|
||||
removeKeyMonitor()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupShortcutHandler() {
|
||||
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
|
||||
Task { @MainActor in
|
||||
await self?.handleShortcutTriggered()
|
||||
await self?.whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
removeKeyMonitor()
|
||||
removeEscapeShortcut()
|
||||
removeEnhancementShortcut()
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ struct SettingsView: View {
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
||||
@State private var showResetOnboardingAlert = false
|
||||
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
|
||||
@State private var hasAccessibilityPermission = AXIsProcessTrusted()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -66,6 +67,24 @@ struct SettingsView: View {
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if hotkeyManager.isPushToTalkEnabled {
|
||||
if !hasAccessibilityPermission {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Please enable Accessibility permissions in System Settings to use Push-to-Talk")
|
||||
.settingsDescription()
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button("Open System Settings") {
|
||||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
if currentShortcut == nil {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
@ -75,24 +94,22 @@ struct SettingsView: View {
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Choose Push-to-Talk Key")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
PushToTalkKeySelector(selectedKey: $hotkeyManager.pushToTalkKey)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
|
||||
|
||||
VideoCTAView(
|
||||
url: "https://dub.sh/shortcut",
|
||||
subtitle: "Pro tip for Push-to-Talk setup"
|
||||
)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Choose Push-to-Talk Key")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
PushToTalkKeySelector(selectedKey: $hotkeyManager.pushToTalkKey)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VideoCTAView(
|
||||
url: "https://dub.sh/shortcut",
|
||||
subtitle: "Pro tip for Push-to-Talk setup"
|
||||
)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -214,6 +231,19 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.onAppear {
|
||||
// Check accessibility permission on appear
|
||||
hasAccessibilityPermission = AXIsProcessTrusted()
|
||||
|
||||
// Start observing accessibility changes
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name("AXIsProcessTrustedChanged"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
hasAccessibilityPermission = AXIsProcessTrusted()
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding", isPresented: $showResetOnboardingAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Reset", role: .destructive) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user