diff --git a/VoiceInk/MediaController.swift b/VoiceInk/MediaController.swift index 9af27ed..5e73794 100644 --- a/VoiceInk/MediaController.swift +++ b/VoiceInk/MediaController.swift @@ -10,21 +10,35 @@ 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 = { + let value = UserDefaults.standard.double(forKey: "audioResumptionDelay") + return value < 1.0 ? 1.0 : value + }() { + 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(1.0, forKey: "audioResumptionDelay") + } else if audioResumptionDelay < 1.0 { + audioResumptionDelay = 1.0 + } } - /// Checks if the system audio is currently muted using AppleScript private func isSystemAudioMuted() -> Bool { let pipe = Pipe() let task = Process() @@ -39,58 +53,58 @@ 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 { + + let delay = audioResumptionDelay + let shouldUnmute = didMuteAudio && !wasAudioMutedBeforeRecording + + 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 } - /// 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..047c638 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,53 +60,60 @@ 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) - + + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + if Task.isCancelled { + return + } + mediaController.play() } diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index b1f8d7c..b4433bc 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 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..089126b --- /dev/null +++ b/VoiceInk/Views/Settings/ExpandableToggleSection.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ExpandableToggleSection: View { + let title: String + let helpText: String + @Binding var isEnabled: Bool + @Binding var isExpanded: Bool + let content: Content + + init( + title: String, + helpText: String, + isEnabled: Binding, + isExpanded: Binding, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.helpText = helpText + self._isEnabled = isEnabled + self._isExpanded = isExpanded + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Toggle(isOn: $isEnabled) { + Text(title) + } + .toggleStyle(.switch) + .help(helpText) + + 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)) { + isExpanded.toggle() + } + } + } + + 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..53164e9 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 isPauseMediaExpanded = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -36,13 +38,37 @@ struct ExperimentalFeaturesSection: View { Divider() .padding(.vertical, 4) .transition(.opacity.combined(with: .move(edge: .top))) - - Toggle(isOn: $playbackController.isPauseMediaEnabled) { - Text("Pause Media during recording") + + ExpandableToggleSection( + title: "Pause Media during recording", + helpText: "Automatically pause active media playback during recordings and resume afterward.", + isEnabled: $playbackController.isPauseMediaEnabled, + isExpanded: $isPauseMediaExpanded + ) { + HStack(spacing: 8) { + Text("Resumption Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + Picker("", selection: $mediaController.audioResumptionDelay) { + 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 Resumption 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, 1s for wired headphones." + ) + + Spacer() + } + .padding(.leading, 16) } - .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..2ed8bde 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -18,11 +18,12 @@ 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 isSystemMuteExpanded = false var body: some View { @@ -220,44 +221,49 @@ 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( + title: "Sound feedback", + helpText: "Play sounds when recording starts and stops", + isEnabled: $soundManager.isEnabled, + isExpanded: $isCustomSoundsExpanded + ) { CustomSoundSettingsView() - .transition(.opacity.combined(with: .move(edge: .top))) - .padding(.top, 4) } Divider() - Toggle(isOn: $mediaController.isSystemMuteEnabled) { - Text("Mute system audio during recording") + ExpandableToggleSection( + title: "Mute system audio during recording", + helpText: "Automatically mute system audio when recording starts and restore when recording stops", + isEnabled: $mediaController.isSystemMuteEnabled, + isExpanded: $isSystemMuteExpanded + ) { + HStack(spacing: 8) { + Text("Resumption Delay") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + Picker("", selection: $mediaController.audioResumptionDelay) { + 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 Resumption 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, 1s for wired headphones." + ) + + Spacer() + } + .padding(.leading, 16) } - .toggleStyle(.switch) - .help("Automatically mute system audio when recording starts and restore when recording stops") + + Divider() VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { @@ -277,17 +283,14 @@ struct SettingsView: View { .foregroundColor(.secondary) 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) + 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: 90) + .frame(width: 80) Spacer() }