Lower Level push-to-talk key detection

This commit is contained in:
Beingpax 2025-03-24 22:36:57 +05:45
parent 854af2a467
commit f02344e5d6
2 changed files with 155 additions and 162 deletions

View File

@ -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()
}

View File

@ -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) {