From 0109f1196d13394870611ae775aa654476253215 Mon Sep 17 00:00:00 2001 From: Jeffrey Rogiers <35710666+jeffreyrr@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:19:02 -0500 Subject: [PATCH] Add hotkey hold-to-record delay setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a configurable hold delay for the push-to-talk hotkey. Adds a new AppState property (hotkeyHoldDelaySeconds) persisted to UserDefaults (key: hotkey_hold_delay_seconds) with values clamped to 0–5 and a default of 1s. Implements a DispatchWorkItem (holdToRecordWorkItem) to start recording only after the configured delay on hotkey down and cancel if released early. Exposes a "Hold delay" settings card with a Picker (0–5 seconds) in SettingsView to allow users to change the delay. --- Sources/AppState.swift | 35 ++++++++++++++++++++++++++++++++++- Sources/SettingsView.swift | 22 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 2592012..ba4830d 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -43,6 +43,7 @@ final class AppState: ObservableObject, @unchecked Sendable { private let customContextPromptStorageKey = "custom_context_prompt" private let customSystemPromptLastModifiedStorageKey = "custom_system_prompt_last_modified" private let customContextPromptLastModifiedStorageKey = "custom_context_prompt_last_modified" + private let hotkeyHoldDelaySecondsStorageKey = "hotkey_hold_delay_seconds" private let transcribingIndicatorDelay: TimeInterval = 1.0 let maxPipelineHistoryCount = 20 @@ -73,6 +74,14 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + @Published var hotkeyHoldDelaySeconds: Int { + didSet { + let clamped = min(5, max(0, hotkeyHoldDelaySeconds)) + if clamped != hotkeyHoldDelaySeconds { hotkeyHoldDelaySeconds = clamped; return } + UserDefaults.standard.set(hotkeyHoldDelaySeconds, forKey: hotkeyHoldDelaySecondsStorageKey) + } + } + @Published var customVocabulary: String { didSet { UserDefaults.standard.set(customVocabulary, forKey: customVocabularyStorageKey) @@ -146,6 +155,7 @@ final class AppState: ObservableObject, @unchecked Sendable { private var hasShownScreenshotPermissionAlert = false private var audioDeviceListenerBlock: AudioObjectPropertyListenerBlock? private let pipelineHistoryStore = PipelineHistoryStore() + private var holdToRecordWorkItem: DispatchWorkItem? init() { let hasCompletedSetup = UserDefaults.standard.bool(forKey: "hasCompletedSetup") @@ -172,11 +182,20 @@ final class AppState: ObservableObject, @unchecked Sendable { let selectedMicrophoneID = UserDefaults.standard.string(forKey: selectedMicrophoneStorageKey) ?? "default" + let hotkeyHoldDelaySeconds: Int + if UserDefaults.standard.object(forKey: "hotkey_hold_delay_seconds") == nil { + hotkeyHoldDelaySeconds = 1 + } else { + let raw = UserDefaults.standard.integer(forKey: "hotkey_hold_delay_seconds") + hotkeyHoldDelaySeconds = min(5, max(0, raw)) + } + self.contextService = AppContextService(apiKey: apiKey, baseURL: apiBaseURL, customContextPrompt: customContextPrompt) self.hasCompletedSetup = hasCompletedSetup self.apiKey = apiKey self.apiBaseURL = apiBaseURL self.selectedHotkey = selectedHotkey + self.hotkeyHoldDelaySeconds = hotkeyHoldDelaySeconds self.customVocabulary = customVocabulary self.customSystemPrompt = customSystemPrompt self.customContextPrompt = customContextPrompt @@ -410,10 +429,24 @@ final class AppState: ObservableObject, @unchecked Sendable { private func handleHotkeyDown() { os_log(.info, log: recordingLog, "handleHotkeyDown() fired, isRecording=%{public}d, isTranscribing=%{public}d", isRecording, isTranscribing) guard !isRecording && !isTranscribing else { return } - startRecording() + holdToRecordWorkItem?.cancel() + holdToRecordWorkItem = nil + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard !self.isRecording && !self.isTranscribing else { return } + self.holdToRecordWorkItem = nil + self.startRecording() + } + holdToRecordWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval(hotkeyHoldDelaySeconds), execute: workItem) } private func handleHotkeyUp() { + if holdToRecordWorkItem != nil { + holdToRecordWorkItem?.cancel() + holdToRecordWorkItem = nil + return + } guard isRecording else { return } stopAndTranscribe() } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d095f86..267e5d0 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -234,6 +234,9 @@ struct GeneralSettingsView: View { SettingsCard("Push-to-Talk Key", icon: "keyboard.fill") { hotkeySection } + SettingsCard("Hold delay", icon: "timer") { + holdDelaySection + } SettingsCard("Microphone", icon: "mic.fill") { microphoneSection } @@ -506,6 +509,25 @@ struct GeneralSettingsView: View { } } + // MARK: Hold delay + + private var holdDelaySection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("How long to hold the key before recording starts. 0 = no delay.") + .font(.caption) + .foregroundStyle(.secondary) + + Picker("Hold delay", selection: $appState.hotkeyHoldDelaySeconds) { + ForEach(0...5, id: \.self) { seconds in + Text(seconds == 0 ? "No delay" : (seconds == 1 ? "1 second" : "\(seconds) seconds")) + .tag(seconds) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + } + // MARK: Microphone private var microphoneSection: some View {