diff --git a/VoiceInk/MediaController.swift b/VoiceInk/MediaController.swift index 9af27ed..07db267 100644 --- a/VoiceInk/MediaController.swift +++ b/VoiceInk/MediaController.swift @@ -10,21 +10,30 @@ class MediaController: ObservableObject { private var didMuteAudio = false private var wasAudioMutedBeforeRecording = false private var currentMuteTask: Task? - + private var unmuteTask: Task? + @Published var isSystemMuteEnabled: Bool = UserDefaults.standard.bool(forKey: "isSystemMuteEnabled") { didSet { UserDefaults.standard.set(isSystemMuteEnabled, forKey: "isSystemMuteEnabled") } } - + + @Published var audioResumptionDelay: Double = UserDefaults.standard.double(forKey: "audioResumptionDelay") { + didSet { + UserDefaults.standard.set(audioResumptionDelay, forKey: "audioResumptionDelay") + } + } + private init() { - // Set default if not already set if !UserDefaults.standard.contains(key: "isSystemMuteEnabled") { UserDefaults.standard.set(true, forKey: "isSystemMuteEnabled") } + + if !UserDefaults.standard.contains(key: "audioResumptionDelay") { + UserDefaults.standard.set(0.0, forKey: "audioResumptionDelay") + } } - /// Checks if the system audio is currently muted using AppleScript private func isSystemAudioMuted() -> Bool { let pipe = Pipe() let task = Process() @@ -39,58 +48,63 @@ class MediaController: ObservableObject { if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { return output == "true" } - } catch { - // Silently fail - } + } catch { } return false } - /// Mutes system audio during recording func muteSystemAudio() async -> Bool { guard isSystemMuteEnabled else { return false } - - // Cancel any existing mute task and create a new one + + unmuteTask?.cancel() + unmuteTask = nil currentMuteTask?.cancel() - + let task = Task { - // First check if audio is already muted wasAudioMutedBeforeRecording = isSystemAudioMuted() - - // If already muted, no need to mute it again + if wasAudioMutedBeforeRecording { return true } - - // Otherwise mute the audio + let success = executeAppleScript(command: "set volume with output muted") didMuteAudio = success return success } - + currentMuteTask = task return await task.value } - /// Restores system audio after recording func unmuteSystemAudio() async { guard isSystemMuteEnabled else { return } - - // Wait for any pending mute operation to complete first + if let muteTask = currentMuteTask { _ = await muteTask.value } - - // Only unmute if we actually muted it (and it wasn't already muted) - if didMuteAudio && !wasAudioMutedBeforeRecording { - _ = executeAppleScript(command: "set volume without output muted") + + let delay = audioResumptionDelay + let shouldUnmute = didMuteAudio && !wasAudioMutedBeforeRecording + + let task = Task { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + if Task.isCancelled { + return + } + + if shouldUnmute { + _ = executeAppleScript(command: "set volume without output muted") + } + + didMuteAudio = false + currentMuteTask = nil } - - didMuteAudio = false - currentMuteTask = nil + + unmuteTask = task + await task.value } - /// Executes an AppleScript command private func executeAppleScript(command: String) -> Bool { let task = Process() task.launchPath = "/usr/bin/osascript" @@ -114,9 +128,14 @@ extension UserDefaults { func contains(key: String) -> Bool { return object(forKey: key) != nil } - + var isSystemMuteEnabled: Bool { get { bool(forKey: "isSystemMuteEnabled") } set { set(newValue, forKey: "isSystemMuteEnabled") } } + + var audioResumptionDelay: Double { + get { double(forKey: "audioResumptionDelay") } + set { set(newValue, forKey: "audioResumptionDelay") } + } } diff --git a/VoiceInk/PlaybackController.swift b/VoiceInk/PlaybackController.swift index 4f238a0..54b280f 100644 --- a/VoiceInk/PlaybackController.swift +++ b/VoiceInk/PlaybackController.swift @@ -10,12 +10,12 @@ class PlaybackController: ObservableObject { private var isMediaPlaying = false private var lastKnownTrackInfo: TrackInfo? private var originalMediaAppBundleId: String? + private var resumeTask: Task? - @Published var isPauseMediaEnabled: Bool = UserDefaults.standard.bool(forKey: "isPauseMediaEnabled") { didSet { UserDefaults.standard.set(isPauseMediaEnabled, forKey: "isPauseMediaEnabled") - + if isPauseMediaEnabled { startMediaTracking() } else { @@ -26,13 +26,13 @@ class PlaybackController: ObservableObject { private init() { mediaController = MediaRemoteAdapter.MediaController() - + if !UserDefaults.standard.contains(key: "isPauseMediaEnabled") { UserDefaults.standard.set(false, forKey: "isPauseMediaEnabled") } - + setupMediaControllerCallbacks() - + if isPauseMediaEnabled { startMediaTracking() } @@ -60,54 +60,66 @@ class PlaybackController: ObservableObject { } func pauseMedia() async { + resumeTask?.cancel() + resumeTask = nil + wasPlayingWhenRecordingStarted = false originalMediaAppBundleId = nil - - guard isPauseMediaEnabled, + + guard isPauseMediaEnabled, isMediaPlaying, lastKnownTrackInfo?.payload.isPlaying == true, let bundleId = lastKnownTrackInfo?.payload.bundleIdentifier else { return } - + wasPlayingWhenRecordingStarted = true originalMediaAppBundleId = bundleId - - // Add a small delay to ensure state is set before sending command - try? await Task.sleep(nanoseconds: 50_000_000) - + + try? await Task.sleep(nanoseconds: 50_000_000) + mediaController.pause() } func resumeMedia() async { let shouldResume = wasPlayingWhenRecordingStarted let originalBundleId = originalMediaAppBundleId - + let delay = MediaController.shared.audioResumptionDelay + defer { wasPlayingWhenRecordingStarted = false originalMediaAppBundleId = nil } - + guard isPauseMediaEnabled, shouldResume, let bundleId = originalBundleId else { return } - + guard isAppStillRunning(bundleId: bundleId) else { return } - + guard let currentTrackInfo = lastKnownTrackInfo, let currentBundleId = currentTrackInfo.payload.bundleIdentifier, currentBundleId == bundleId, currentTrackInfo.payload.isPlaying == false else { return } - - try? await Task.sleep(nanoseconds: 50_000_000) - - mediaController.play() + + let task = Task { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + if Task.isCancelled { + return + } + + mediaController.play() + } + + resumeTask = task + await task.value } private func isAppStillRunning(bundleId: String) -> Bool { diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index b1f8d7c..b7c0a3c 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -15,6 +15,7 @@ class Recorder: NSObject, ObservableObject { @Published var audioMeter = AudioMeter(averagePower: 0, peakPower: 0) private var audioLevelCheckTask: Task? private var audioMeterUpdateTask: Task? + private var audioRestorationTask: Task? private var hasDetectedAudioInCurrentSession = false enum RecorderError: Error { @@ -95,6 +96,9 @@ class Recorder: NSObject, ObservableObject { logger.info("✅ AudioEngineRecorder started successfully") + audioRestorationTask?.cancel() + audioRestorationTask = nil + Task { [weak self] in guard let self = self else { return } await self.playbackController.pauseMedia() @@ -146,9 +150,8 @@ class Recorder: NSObject, ObservableObject { recorder = nil audioMeter = AudioMeter(averagePower: 0, peakPower: 0) - Task { + audioRestorationTask = Task { await mediaController.unmuteSystemAudio() - try? await Task.sleep(nanoseconds: 100_000_000) await playbackController.resumeMedia() } deviceManager.isRecordingActive = false @@ -210,6 +213,7 @@ class Recorder: NSObject, ObservableObject { deinit { audioLevelCheckTask?.cancel() audioMeterUpdateTask?.cancel() + audioRestorationTask?.cancel() if let observer = deviceObserver { NotificationCenter.default.removeObserver(observer) } diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 386c2bb..256977b 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -23,6 +23,7 @@ struct GeneralSettings: Codable { let isSoundFeedbackEnabled: Bool? let isSystemMuteEnabled: Bool? let isPauseMediaEnabled: Bool? + let audioResumptionDelay: Double? let isTextFormattingEnabled: Bool? let isExperimentalFeaturesEnabled: Bool? let restoreClipboardAfterPaste: Bool? @@ -116,6 +117,7 @@ class ImportExportService { isSoundFeedbackEnabled: soundManager.isEnabled, isSystemMuteEnabled: mediaController.isSystemMuteEnabled, isPauseMediaEnabled: playbackController.isPauseMediaEnabled, + audioResumptionDelay: mediaController.audioResumptionDelay, isTextFormattingEnabled: UserDefaults.standard.object(forKey: keyIsTextFormattingEnabled) as? Bool ?? true, isExperimentalFeaturesEnabled: UserDefaults.standard.bool(forKey: "isExperimentalFeaturesEnabled"), restoreClipboardAfterPaste: UserDefaults.standard.bool(forKey: "restoreClipboardAfterPaste"), @@ -323,6 +325,9 @@ class ImportExportService { if let pauseMedia = general.isPauseMediaEnabled { playbackController.isPauseMediaEnabled = pauseMedia } + if let audioDelay = general.audioResumptionDelay { + mediaController.audioResumptionDelay = audioDelay + } if let experimentalEnabled = general.isExperimentalFeaturesEnabled { UserDefaults.standard.set(experimentalEnabled, forKey: "isExperimentalFeaturesEnabled") if experimentalEnabled == false { diff --git a/VoiceInk/Views/Settings/ExpandableToggleSection.swift b/VoiceInk/Views/Settings/ExpandableToggleSection.swift new file mode 100644 index 0000000..1b903ca --- /dev/null +++ b/VoiceInk/Views/Settings/ExpandableToggleSection.swift @@ -0,0 +1,88 @@ +import SwiftUI + +enum ExpandableSection: Hashable { + case soundFeedback + case systemMute + case pauseMedia + case clipboardRestore + case customCancel + case middleClick +} + +struct ExpandableToggleSection: View { + let section: ExpandableSection + let title: String + let helpText: String + @Binding var isEnabled: Bool + @Binding var expandedSections: Set + let content: Content + + init( + section: ExpandableSection, + title: String, + helpText: String, + isEnabled: Binding, + expandedSections: Binding>, + @ViewBuilder content: () -> Content + ) { + self.section = section + self.title = title + self.helpText = helpText + self._isEnabled = isEnabled + self._expandedSections = expandedSections + self.content = content() + } + + private var isExpanded: Bool { + expandedSections.contains(section) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Toggle(isOn: $isEnabled) { + Text(title) + } + .toggleStyle(.switch) + .help(helpText) + .onChange(of: isEnabled) { _, newValue in + withAnimation(.easeInOut(duration: 0.2)) { + if newValue { + _ = expandedSections.insert(section) + } else { + expandedSections.remove(section) + } + } + } + + if isEnabled { + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if isEnabled { + withAnimation(.easeInOut(duration: 0.2)) { + if isExpanded { + expandedSections.remove(section) + } else { + _ = expandedSections.insert(section) + } + } + } + } + + if isEnabled && isExpanded { + content + .transition(.opacity.combined(with: .move(edge: .top))) + .padding(.top, 4) + } + } + } +} diff --git a/VoiceInk/Views/Settings/ExperimentalFeaturesSection.swift b/VoiceInk/Views/Settings/ExperimentalFeaturesSection.swift index b4d1d01..c30f5af 100644 --- a/VoiceInk/Views/Settings/ExperimentalFeaturesSection.swift +++ b/VoiceInk/Views/Settings/ExperimentalFeaturesSection.swift @@ -3,6 +3,8 @@ import SwiftUI struct ExperimentalFeaturesSection: View { @AppStorage("isExperimentalFeaturesEnabled") private var isExperimentalFeaturesEnabled = false @ObservedObject private var playbackController = PlaybackController.shared + @ObservedObject private var mediaController = MediaController.shared + @State private var expandedSections: Set = [] var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -36,13 +38,38 @@ struct ExperimentalFeaturesSection: View { Divider() .padding(.vertical, 4) .transition(.opacity.combined(with: .move(edge: .top))) - - Toggle(isOn: $playbackController.isPauseMediaEnabled) { - Text("Pause Media during recording") + + ExpandableToggleSection( + section: .pauseMedia, + title: "Pause Media during recording", + helpText: "Automatically pause active media playback during recordings and resume afterward.", + isEnabled: $playbackController.isPauseMediaEnabled, + expandedSections: $expandedSections + ) { + HStack(spacing: 8) { + Text("Resume Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + Picker("", selection: $mediaController.audioResumptionDelay) { + Text("0s").tag(0.0) + Text("1s").tag(1.0) + Text("2s").tag(2.0) + Text("3s").tag(3.0) + Text("4s").tag(4.0) + Text("5s").tag(5.0) + } + .pickerStyle(.menu) + .frame(width: 80) + + InfoTip( + title: "Audio Resume Delay", + message: "Delay before resuming media playback after recording stops. Useful for Bluetooth headphones that need time to switch from microphone mode back to high-quality audio mode. Recommended: 2s for AirPods/Bluetooth headphones, 0s for wired headphones." + ) + + Spacer() + } } - .toggleStyle(.switch) - .help("Automatically pause active media playback during recordings and resume afterward.") - .transition(.opacity.combined(with: .move(edge: .top))) } } .animation(.easeInOut(duration: 0.3), value: isExperimentalFeaturesEnabled) diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index c1edb4d..54b31f0 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -18,11 +18,11 @@ struct SettingsView: View { @AppStorage("autoUpdateCheck") private var autoUpdateCheck = true @AppStorage("enableAnnouncements") private var enableAnnouncements = true @AppStorage("restoreClipboardAfterPaste") private var restoreClipboardAfterPaste = false - @AppStorage("clipboardRestoreDelay") private var clipboardRestoreDelay = 1.5 + @AppStorage("clipboardRestoreDelay") private var clipboardRestoreDelay = 2.0 @State private var showResetOnboardingAlert = false @State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) @State private var isCustomCancelEnabled = false - @State private var isCustomSoundsExpanded = false + @State private var expandedSections: Set = [] var body: some View { @@ -134,81 +134,62 @@ struct SettingsView: View { Divider() - - - // Custom Cancel Shortcut - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Toggle(isOn: $isCustomCancelEnabled.animation()) { - Text("Custom Cancel Shortcut") - } - .toggleStyle(.switch) - .onChange(of: isCustomCancelEnabled) { _, newValue in - if !newValue { - KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder) - } - } - - InfoTip( - title: "Dismiss Recording", - message: "Shortcut for cancelling the current recording session. Default: double-tap Escape." - ) + + + ExpandableToggleSection( + section: .customCancel, + title: "Custom Cancel Shortcut", + helpText: "Shortcut for cancelling the current recording session. Default: double-tap Escape.", + isEnabled: $isCustomCancelEnabled, + expandedSections: $expandedSections + ) { + HStack(spacing: 12) { + Text("Cancel Shortcut") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + KeyboardShortcuts.Recorder(for: .cancelRecorder) + .controlSize(.small) + + Spacer() } - - if isCustomCancelEnabled { - HStack(spacing: 12) { - Text("Cancel Shortcut") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - - KeyboardShortcuts.Recorder(for: .cancelRecorder) - .controlSize(.small) - - Spacer() - } - .padding(.leading, 16) - .transition(.opacity.combined(with: .move(edge: .top))) + } + .onChange(of: isCustomCancelEnabled) { _, newValue in + if !newValue { + KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder) } } Divider() - // Middle-Click Toggle - VStack(alignment: .leading, spacing: 12) { + ExpandableToggleSection( + section: .middleClick, + title: "Enable Middle-Click Toggle", + helpText: "Use middle mouse button to toggle VoiceInk recording.", + isEnabled: $hotkeyManager.isMiddleClickToggleEnabled, + expandedSections: $expandedSections + ) { HStack(spacing: 8) { - Toggle("Enable Middle-Click Toggle", isOn: $hotkeyManager.isMiddleClickToggleEnabled) - .toggleStyle(.switch) + Text("Activation Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) - InfoTip( - title: "Middle-Click Toggle", - message: "Use middle mouse button to toggle VoiceInk recording." - ) - } + TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.minimum = 0 + return formatter + }()) + .textFieldStyle(PlainTextFieldStyle()) + .padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6)) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(5) + .frame(width: 70) - if hotkeyManager.isMiddleClickToggleEnabled { - HStack(spacing: 8) { - Text("Activation Delay") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) + Text("ms") + .foregroundColor(.secondary) - TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: { - let formatter = NumberFormatter() - formatter.numberStyle = .none - formatter.minimum = 0 - return formatter - }()) - .textFieldStyle(PlainTextFieldStyle()) - .padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6)) - .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(5) - .frame(width: 70) - - Text("ms") - .foregroundColor(.secondary) - - Spacer() - } - .padding(.leading, 16) + Spacer() } } } @@ -220,78 +201,75 @@ struct SettingsView: View { subtitle: "Customize app & system feedback" ) { VStack(alignment: .leading, spacing: 12) { - HStack { - Toggle(isOn: $soundManager.isEnabled) { - Text("Sound feedback") - } - .toggleStyle(.switch) - - if soundManager.isEnabled { - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - .rotationEffect(.degrees(isCustomSoundsExpanded ? 90 : 0)) - .animation(.easeInOut(duration: 0.2), value: isCustomSoundsExpanded) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if soundManager.isEnabled { - withAnimation(.easeInOut(duration: 0.2)) { - isCustomSoundsExpanded.toggle() - } - } - } - - if soundManager.isEnabled && isCustomSoundsExpanded { + ExpandableToggleSection( + section: .soundFeedback, + title: "Sound feedback", + helpText: "Play sounds when recording starts and stops", + isEnabled: $soundManager.isEnabled, + expandedSections: $expandedSections + ) { CustomSoundSettingsView() - .transition(.opacity.combined(with: .move(edge: .top))) - .padding(.top, 4) } Divider() - Toggle(isOn: $mediaController.isSystemMuteEnabled) { - Text("Mute system audio during recording") - } - .toggleStyle(.switch) - .help("Automatically mute system audio when recording starts and restore when recording stops") - - VStack(alignment: .leading, spacing: 12) { + ExpandableToggleSection( + section: .systemMute, + title: "Mute system audio during recording", + helpText: "Automatically mute system audio when recording starts and restore when recording stops", + isEnabled: $mediaController.isSystemMuteEnabled, + expandedSections: $expandedSections + ) { HStack(spacing: 8) { - Toggle("Restore clipboard after paste", isOn: $restoreClipboardAfterPaste) - .toggleStyle(.switch) + Text("Resume Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + Picker("", selection: $mediaController.audioResumptionDelay) { + Text("0s").tag(0.0) + Text("1s").tag(1.0) + Text("2s").tag(2.0) + Text("3s").tag(3.0) + Text("4s").tag(4.0) + Text("5s").tag(5.0) + } + .pickerStyle(.menu) + .frame(width: 80) InfoTip( - title: "Restore Clipboard", - message: "When enabled, VoiceInk will restore your original clipboard content after pasting the transcription." + title: "Audio Resume Delay", + message: "Delay before unmuting system audio after recording stops. Useful for Bluetooth headphones that need time to switch from microphone mode back to high-quality audio mode. Recommended: 2s for AirPods/Bluetooth headphones, 0s for wired headphones." ) + + Spacer() } + } - if restoreClipboardAfterPaste { - HStack(spacing: 8) { - Text("Restore Delay") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) + Divider() - Picker("", selection: $clipboardRestoreDelay) { - Text("0.5s").tag(0.5) - Text("1.0s").tag(1.0) - Text("1.5s").tag(1.5) - Text("2.0s").tag(2.0) - Text("2.5s").tag(2.5) - Text("3.0s").tag(3.0) - Text("4.0s").tag(4.0) - Text("5.0s").tag(5.0) - } - .pickerStyle(.menu) - .frame(width: 90) + ExpandableToggleSection( + section: .clipboardRestore, + title: "Restore clipboard after paste", + helpText: "When enabled, VoiceInk will restore your original clipboard content after pasting the transcription.", + isEnabled: $restoreClipboardAfterPaste, + expandedSections: $expandedSections + ) { + HStack(spacing: 8) { + Text("Restore Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) - Spacer() + Picker("", selection: $clipboardRestoreDelay) { + Text("1s").tag(1.0) + Text("2s").tag(2.0) + Text("3s").tag(3.0) + Text("4s").tag(4.0) + Text("5s").tag(5.0) } - .padding(.leading, 16) + .pickerStyle(.menu) + .frame(width: 80) + + Spacer() } }