From af7b7e61295d4b2372198d828d638ed34384dce7 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Tue, 20 Jan 2026 13:55:56 -0800 Subject: [PATCH 1/6] Add preferences --- .gitignore | 1 + AudioPriorityBar.xcodeproj/project.pbxproj | 12 ++ AudioPriorityBar/AudioPriorityBarApp.swift | 2 +- AudioPriorityBar/Models/AppInfo.swift | 24 +++ .../PreferencesWindowController.swift | 48 +++++ AudioPriorityBar/Views/MenuBarView.swift | 14 +- AudioPriorityBar/Views/PreferencesView.swift | 191 ++++++++++++++++++ 7 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 AudioPriorityBar/Models/AppInfo.swift create mode 100644 AudioPriorityBar/Services/PreferencesWindowController.swift create mode 100644 AudioPriorityBar/Views/PreferencesView.swift diff --git a/.gitignore b/.gitignore index 3ec538d..a18acf0 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ Index/ # xcode-build-server files buildServer.json .compile +.claude diff --git a/AudioPriorityBar.xcodeproj/project.pbxproj b/AudioPriorityBar.xcodeproj/project.pbxproj index 4ed325d..f7ac655 100644 --- a/AudioPriorityBar.xcodeproj/project.pbxproj +++ b/AudioPriorityBar.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ A10000000000000000000009 /* Headphones.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Headphones.swift */; }; A10000000000000000000010 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; }; A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* Assets.xcassets */; }; + 4DD6D82E5CF6D899F9B4F327 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622640DCAA11DD5022FC5DBF /* AppInfo.swift */; }; + 5EACF86E108BAB90FAC19892 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */; }; + ED800EC86195D082D84E8604 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -32,6 +35,9 @@ A20000000000000000000010 /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = System/Library/Frameworks/CoreAudio.framework; sourceTree = SDKROOT; }; A20000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A30000000000000000000001 /* AudioPriorityBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPriorityBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 622640DCAA11DD5022FC5DBF /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; + DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = ""; }; + BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -73,6 +79,7 @@ children = ( A20000000000000000000002 /* AudioDevice.swift */, A20000000000000000000009 /* Headphones.swift */, + 622640DCAA11DD5022FC5DBF /* AppInfo.swift */, ); path = Models; sourceTree = ""; @@ -83,6 +90,7 @@ A20000000000000000000003 /* AudioDeviceService.swift */, A20000000000000000000004 /* PriorityManager.swift */, A20000000000000000000008 /* LaunchAtLoginManager.swift */, + DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */, ); path = Services; sourceTree = ""; @@ -92,6 +100,7 @@ children = ( A20000000000000000000005 /* MenuBarView.swift */, A20000000000000000000006 /* DeviceListView.swift */, + BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */, ); path = Views; sourceTree = ""; @@ -184,10 +193,13 @@ A10000000000000000000001 /* AudioPriorityBarApp.swift in Sources */, A10000000000000000000002 /* AudioDevice.swift in Sources */, A10000000000000000000009 /* Headphones.swift in Sources */, + 4DD6D82E5CF6D899F9B4F327 /* AppInfo.swift in Sources */, A10000000000000000000003 /* AudioDeviceService.swift in Sources */, A10000000000000000000004 /* PriorityManager.swift in Sources */, + 5EACF86E108BAB90FAC19892 /* PreferencesWindowController.swift in Sources */, A10000000000000000000005 /* MenuBarView.swift in Sources */, A10000000000000000000006 /* DeviceListView.swift in Sources */, + ED800EC86195D082D84E8604 /* PreferencesView.swift in Sources */, A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 3f455e9..cd454d1 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -4,7 +4,7 @@ import CoreAudio @main struct AudioPriorityBarApp: App { @StateObject private var audioManager = AudioManager() - + var body: some Scene { MenuBarExtra { MenuBarView() diff --git a/AudioPriorityBar/Models/AppInfo.swift b/AudioPriorityBar/Models/AppInfo.swift new file mode 100644 index 0000000..645db96 --- /dev/null +++ b/AudioPriorityBar/Models/AppInfo.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Utility struct to access app version and build information +struct AppInfo { + /// The short version string (e.g., "1.0.0") + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + } + + /// The build number (e.g., "42") + static var build: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + } + + /// Combined version and build string (e.g., "Version 1.0.0 (42)") + static var versionString: String { + "Version \(version) (\(build))" + } + + /// App name from bundle + static var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Audio Priority Bar" + } +} diff --git a/AudioPriorityBar/Services/PreferencesWindowController.swift b/AudioPriorityBar/Services/PreferencesWindowController.swift new file mode 100644 index 0000000..75b06e8 --- /dev/null +++ b/AudioPriorityBar/Services/PreferencesWindowController.swift @@ -0,0 +1,48 @@ +import AppKit +import SwiftUI + +/// Singleton controller to manage the preferences window lifecycle +class PreferencesWindowController { + /// Shared singleton instance + static let shared = PreferencesWindowController() + + /// The preferences window instance + private var window: NSWindow? + + /// Private initializer to enforce singleton pattern + private init() {} + + /// Shows the preferences window, creating it if needed or bringing it to front if already exists + /// - Parameter audioManager: The audio manager to pass to the preferences view + func show(audioManager: AudioManager) { + if let existingWindow = window { + // Window already exists, just bring to front + existingWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } else { + // Create new window + let preferencesView = PreferencesView() + .environmentObject(audioManager) + + let hostingController = NSHostingController(rootView: preferencesView) + + let newWindow = NSWindow(contentViewController: hostingController) + newWindow.title = "Audio Priority Bar Preferences" + newWindow.styleMask = [.titled, .closable, .miniaturizable] + newWindow.setContentSize(NSSize(width: 480, height: 400)) + newWindow.center() + newWindow.level = .floating + newWindow.isReleasedWhenClosed = false + newWindow.setFrameAutosaveName("PreferencesWindow") + + self.window = newWindow + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + } + + /// Closes the preferences window + func close() { + window?.close() + } +} diff --git a/AudioPriorityBar/Views/MenuBarView.swift b/AudioPriorityBar/Views/MenuBarView.swift index 3ad55c2..b89dd01 100644 --- a/AudioPriorityBar/Views/MenuBarView.swift +++ b/AudioPriorityBar/Views/MenuBarView.swift @@ -96,9 +96,17 @@ struct MenuBarView: View { } Spacer() - - // Launch at login toggle - LaunchAtLoginToggle() + + // Settings button + Button { + PreferencesWindowController.shared.show(audioManager: audioManager) + } label: { + Image(systemName: "gearshape") + .font(.system(size: 14)) + .foregroundColor(.secondary.opacity(0.6)) + } + .buttonStyle(.plain) + .help("Preferences") // Edit mode toggle Button { diff --git a/AudioPriorityBar/Views/PreferencesView.swift b/AudioPriorityBar/Views/PreferencesView.swift new file mode 100644 index 0000000..89a290f --- /dev/null +++ b/AudioPriorityBar/Views/PreferencesView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +/// Main preferences view with General and About tabs +struct PreferencesView: View { + @EnvironmentObject var audioManager: AudioManager + @State private var selectedTab: PreferencesTab = .general + + enum PreferencesTab: String, CaseIterable { + case general = "General" + case about = "About" + } + + var body: some View { + VStack(spacing: 0) { + // Tab picker + Picker("", selection: $selectedTab) { + ForEach(PreferencesTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + Divider() + + // Tab content + Group { + switch selectedTab { + case .general: + GeneralPreferencesTab() + .transition(.opacity) + case .about: + AboutPreferencesTab() + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.15), value: selectedTab) + } + .frame(width: 480, height: 400) + } +} + +/// General preferences tab +struct GeneralPreferencesTab: View { + @StateObject private var launchManager = LaunchAtLoginManager.shared + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Launch at Login section + GroupBox { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: $launchManager.isEnabled) { + VStack(alignment: .leading, spacing: 4) { + Text("Launch at Login") + .font(.system(size: 13, weight: .medium)) + Text("Automatically start Audio Priority Bar when you log in") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + } + .padding(12) + } label: { + Label("Startup", systemImage: "power.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) + + // Auto-switching section + GroupBox { + VStack(alignment: .leading, spacing: 8) { + Text("Auto-Switching Behavior") + .font(.system(size: 13, weight: .medium)) + + Text("Audio Priority Bar automatically switches between speakers and headphones based on your device priorities. Use the mode toggle to switch between Speaker and Headphone modes, or enable Manual mode to disable auto-switching.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + } label: { + Label("Auto-Switching", systemImage: "arrow.left.arrow.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) + + Spacer() + } + .padding(20) + } + } +} + +/// About preferences tab +struct AboutPreferencesTab: View { + var body: some View { + ScrollView { + VStack(spacing: 24) { + // App icon and name + VStack(spacing: 12) { + if let appIcon = NSImage(named: "AppIcon") { + Image(nsImage: appIcon) + .resizable() + .frame(width: 80, height: 80) + .cornerRadius(12) + } + + Text(AppInfo.appName) + .font(.system(size: 18, weight: .semibold)) + + Text(AppInfo.versionString) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.top, 20) + + Divider() + .padding(.horizontal, 40) + + // Description + VStack(alignment: .leading, spacing: 12) { + Text("About") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + + Text("Audio Priority Bar is a menu bar utility for macOS that helps you quickly switch between audio devices and manage your sound output priorities.") + .font(.system(size: 13)) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: 400) + .padding(.horizontal, 20) + + // GitHub link + VStack(spacing: 10) { + Button { + if let url = URL(string: "https://github.com/johanhenkens/AudioPriorityBar") { + NSWorkspace.shared.open(url) + } + } label: { + HStack(spacing: 6) { + Image(systemName: "link") + .font(.system(size: 12)) + Text("View on GitHub") + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(.accentColor) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.accentColor.opacity(0.1)) + ) + } + .buttonStyle(.plain) + } + + Spacer() + } + .padding(.bottom, 20) + } + } +} + +/// Custom group box style matching MenuBarView design +struct PreferencesGroupBoxStyle: GroupBoxStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 8) { + configuration.label + .padding(.horizontal, 4) + + configuration.content + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.primary.opacity(0.04)) + ) + } +} From b76359a2d2d9928bfba5f955485dea15e2d91bd8 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Tue, 20 Jan 2026 14:05:21 -0800 Subject: [PATCH 2/6] Fix image --- AudioPriorityBar/AudioPriorityBarApp.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index cd454d1..5b4438d 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -10,7 +10,14 @@ struct AudioPriorityBarApp: App { MenuBarView() .environmentObject(audioManager) } label: { - Image(systemName: "speaker.wave.2.fill") + MenuBarLabel( + volume: audioManager.volume, + isOutputMuted: audioManager.isActiveOutputMuted, + isInputMuted: audioManager.isActiveInputMuted, + isCustomMode: audioManager.isCustomMode, + mode: audioManager.currentMode, + micFlash: audioManager.micFlashState + ) } .menuBarExtraStyle(.window) } From b104dd24804596fbc7421f7477ac2d31c5e53a8b Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Tue, 20 Jan 2026 14:55:25 -0800 Subject: [PATCH 3/6] Add priority override for each device --- AudioPriorityBar/AudioPriorityBarApp.swift | 75 +++++++++- .../Services/AudioDeviceService.swift | 138 ++++++++++++------ .../Services/PriorityManager.swift | 24 +++ AudioPriorityBar/Views/DeviceListView.swift | 20 +++ 4 files changed, 203 insertions(+), 54 deletions(-) diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 5b4438d..2282b8b 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -1,5 +1,6 @@ import SwiftUI import CoreAudio +import OSLog @main struct AudioPriorityBarApp: App { @@ -100,6 +101,11 @@ class AudioManager: ObservableObject { let priorityManager = PriorityManager() private var connectedDeviceUIDs: Set = [] + private let logger = Logger(subsystem: "com.audioprioritybar", category: "AudioManager") + private var handleDeviceChangeCount = 0 + private var applyInputCount = 0 + private var applyOutputCount = 0 + var menuBarIcon: String { currentMode.icon } @@ -357,6 +363,18 @@ class AudioManager: ObservableObject { } } + func setPreferredInput(_ inputDevice: AudioDevice, forOutput outputUID: String) { + priorityManager.setPreferredInput(inputDevice.uid, forOutput: outputUID) + } + + func clearPreferredInput(forOutput outputUID: String) { + priorityManager.clearPreferredInput(forOutput: outputUID) + } + + func getPreferredInputUID(forOutput outputUID: String) -> String? { + priorityManager.getPreferredInput(forOutput: outputUID) + } + func moveInputDevice(from source: IndexSet, to destination: Int) { inputDevices.move(fromOffsets: source, toOffset: destination) priorityManager.savePriorities(inputDevices, type: .input) @@ -393,13 +411,44 @@ class AudioManager: ObservableObject { } private func applyInputDevice(_ device: AudioDevice) { + applyInputCount += 1 + logger.debug("🎤 applyInputDevice #\(self.applyInputCount) - device: \(device.name) (id: \(device.id)), current: \(String(describing: self.currentInputId))") + + guard currentInputId != device.id else { + logger.trace(" → Skipping: already current device") + return + } + deviceService.setDefaultDevice(device.id, type: .input) currentInputId = device.id + logger.debug(" ✅ Input device set") } private func applyOutputDevice(_ device: AudioDevice) { + applyOutputCount += 1 + logger.debug("🔊 applyOutputDevice #\(self.applyOutputCount) - device: \(device.name) (id: \(device.id)), current: \(String(describing: self.currentOutputId))") + + guard currentOutputId != device.id else { + logger.trace(" → Skipping: already current device") + // Still check for preferred input even if output didn't change + if let preferredInputUID = priorityManager.getPreferredInput(forOutput: device.uid), + let preferredInput = inputDevices.first(where: { $0.uid == preferredInputUID && $0.isConnected && !priorityManager.isNeverUse($0) }) { + logger.debug(" → Applying preferred input for this output") + applyInputDevice(preferredInput) + } + return + } + deviceService.setDefaultDevice(device.id, type: .output) currentOutputId = device.id + logger.debug(" ✅ Output device set") + + // Check if there's a preferred input for this output device + if let preferredInputUID = priorityManager.getPreferredInput(forOutput: device.uid), + let preferredInput = inputDevices.first(where: { $0.uid == preferredInputUID && $0.isConnected && !priorityManager.isNeverUse($0) }) { + logger.debug(" → Applying preferred input for this output") + applyInputDevice(preferredInput) + } } private func applyHighestPriorityInput() { @@ -417,29 +466,43 @@ class AudioManager: ObservableObject { } private func setupDeviceChangeListener() { - deviceService.onDevicesChanged = { [weak self] in + deviceService.onDevicesChanged = { [weak self] isDeviceListChange in Task { @MainActor in - self?.handleDeviceChange() + self?.handleDeviceChange(isDeviceListChange: isDeviceListChange) } } deviceService.startListening() } - private func handleDeviceChange() { + private func handleDeviceChange(isDeviceListChange: Bool) { + handleDeviceChangeCount += 1 + logger.debug("⚡️ handleDeviceChange #\(self.handleDeviceChangeCount) triggered - deviceListChange: \(isDeviceListChange)") + let oldConnectedUIDs = previousConnectedUIDs refreshDevices() refreshMuteStatus() - + // Detect newly connected devices let newlyConnectedUIDs = connectedDeviceUIDs.subtracting(oldConnectedUIDs) previousConnectedUIDs = connectedDeviceUIDs - - if !isCustomMode { + + logger.debug(" → Newly connected devices: \(newlyConnectedUIDs.count), custom mode: \(self.isCustomMode)") + + if !isCustomMode && isDeviceListChange { + // Only apply priorities when devices are added/removed, NOT when just switching + logger.debug(" → Device list changed - applying highest priority devices") // Auto-switch mode only when a new headphone connects or all headphones disconnect autoSwitchModeIfNeeded(newlyConnectedUIDs: newlyConnectedUIDs) applyHighestPriorityInput() applyHighestPriorityOutput() + } else if !isCustomMode { + logger.debug(" → Default device switched - NOT reapplying priorities (prevents loop)") + // Just update the UI, don't reapply priorities - this prevents the loop! + } else { + logger.debug(" → Custom mode enabled - not applying priorities") } + + logger.debug(" ✅ handleDeviceChange complete") } /// Automatically switches between headphone and speaker mode based on device connections. diff --git a/AudioPriorityBar/Services/AudioDeviceService.swift b/AudioPriorityBar/Services/AudioDeviceService.swift index 43431fe..4176ccd 100644 --- a/AudioPriorityBar/Services/AudioDeviceService.swift +++ b/AudioPriorityBar/Services/AudioDeviceService.swift @@ -1,15 +1,22 @@ import Foundation import CoreAudio import AudioToolbox +import OSLog class AudioDeviceService { - var onDevicesChanged: (() -> Void)? + var onDevicesChanged: ((_ isDeviceListChange: Bool) -> Void)? var onMuteOrVolumeChanged: (() -> Void)? private var listenerBlock: AudioObjectPropertyListenerBlock? + private var defaultDeviceListenerBlock: AudioObjectPropertyListenerBlock? private var muteVolumeListenerBlock: AudioObjectPropertyListenerBlock? private var monitoredDeviceIds: Set = [] + private let logger = Logger(subsystem: "com.audioprioritybar", category: "AudioDeviceService") + private var deviceChangeCount = 0 + private var muteVolumeChangeCount = 0 + private var updateListenersCount = 0 + func getDevices() -> [AudioDevice] { var propertyAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, @@ -87,6 +94,8 @@ class AudioDeviceService { ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice + logger.debug("📍 setDefaultDevice called - type: \(type == .input ? "input" : "output"), deviceId: \(deviceId)") + var propertyAddress = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, @@ -104,6 +113,8 @@ class AudioDeviceService { dataSize, &mutableDeviceId ) + + logger.debug("✅ setDefaultDevice completed - type: \(type == .input ? "input" : "output"), deviceId: \(deviceId)") } func getOutputVolume() -> Float { @@ -229,26 +240,46 @@ class AudioDeviceService { } func startListening() { - var propertyAddress = AudioObjectPropertyAddress( + logger.debug("🎧 startListening called") + + // Listener for device list changes (devices added/removed) + var deviceListAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) listenerBlock = { [weak self] _, _ in - self?.onDevicesChanged?() - // Re-register mute/volume listeners when devices change - self?.updateMuteVolumeListeners() + guard let self = self else { return } + self.deviceChangeCount += 1 + self.logger.debug("🔔 Device LIST changed #\(self.deviceChangeCount) - devices added/removed") + + // Notify with isDeviceListChange = true + self.onDevicesChanged?(true) + + // Re-register mute/volume listeners when devices are added/removed + self.logger.debug(" → Updating mute/volume listeners") + self.updateMuteVolumeListeners() } AudioObjectAddPropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), - &propertyAddress, + &deviceListAddress, DispatchQueue.main, listenerBlock! ) - // Also listen to default device changes + // Separate listener for default device changes (just switching devices) + defaultDeviceListenerBlock = { [weak self] _, _ in + guard let self = self else { return } + self.deviceChangeCount += 1 + self.logger.debug("🔔 Default device SWITCHED #\(self.deviceChangeCount) - user/system changed active device") + + // Notify with isDeviceListChange = false - DON'T reapply priorities + self.onDevicesChanged?(false) + } + + // Listen to default input device changes var inputDefaultAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, @@ -258,9 +289,10 @@ class AudioDeviceService { AudioObjectID(kAudioObjectSystemObject), &inputDefaultAddress, DispatchQueue.main, - listenerBlock! + defaultDeviceListenerBlock! ) + // Listen to default output device changes var outputDefaultAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, @@ -270,20 +302,27 @@ class AudioDeviceService { AudioObjectID(kAudioObjectSystemObject), &outputDefaultAddress, DispatchQueue.main, - listenerBlock! + defaultDeviceListenerBlock! ) // Initial setup of mute/volume listeners + logger.debug("🎧 Setting up initial mute/volume listeners") updateMuteVolumeListeners() } func updateMuteVolumeListeners() { + updateListenersCount += 1 + logger.debug("🔄 updateMuteVolumeListeners called #\(self.updateListenersCount)") + // Remove old listeners removeMuteVolumeListeners() // Create listener block muteVolumeListenerBlock = { [weak self] _, _ in - self?.onMuteOrVolumeChanged?() + guard let self = self else { return } + self.muteVolumeChangeCount += 1 + self.logger.trace("🔊 Mute/volume listener fired #\(self.muteVolumeChangeCount)") + self.onMuteOrVolumeChanged?() } // Get all current device IDs @@ -389,47 +428,50 @@ class AudioDeviceService { // Remove mute/volume listeners first removeMuteVolumeListeners() - guard let block = listenerBlock else { return } - - var propertyAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - - AudioObjectRemovePropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &propertyAddress, - DispatchQueue.main, - block - ) + // Remove device list listener + if let block = listenerBlock { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) - // Also remove default device change listeners - var inputDefaultAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultInputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - AudioObjectRemovePropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &inputDefaultAddress, - DispatchQueue.main, - block - ) + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + DispatchQueue.main, + block + ) + listenerBlock = nil + } - var outputDefaultAddress = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultOutputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - AudioObjectRemovePropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &outputDefaultAddress, - DispatchQueue.main, - block - ) + // Remove default device change listeners + if let defaultBlock = defaultDeviceListenerBlock { + var inputDefaultAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &inputDefaultAddress, + DispatchQueue.main, + defaultBlock + ) - listenerBlock = nil + var outputDefaultAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &outputDefaultAddress, + DispatchQueue.main, + defaultBlock + ) + defaultDeviceListenerBlock = nil + } } private func createDevice(id: AudioObjectID, type: AudioDeviceType) -> AudioDevice? { diff --git a/AudioPriorityBar/Services/PriorityManager.swift b/AudioPriorityBar/Services/PriorityManager.swift index 337dc2f..a56fa66 100644 --- a/AudioPriorityBar/Services/PriorityManager.swift +++ b/AudioPriorityBar/Services/PriorityManager.swift @@ -148,6 +148,30 @@ class PriorityManager { private let hiddenSpeakersKey = "hiddenSpeakers" private let hiddenHeadphonesKey = "hiddenHeadphones" + // MARK: - Preferred Inputs for Outputs + + private let preferredInputsKey = "preferredInputsForOutputs" + + /// Get the preferred input device UID for a given output device + func getPreferredInput(forOutput outputUID: String) -> String? { + let mappings = defaults.dictionary(forKey: preferredInputsKey) as? [String: String] ?? [:] + return mappings[outputUID] + } + + /// Set a preferred input device for a given output device + func setPreferredInput(_ inputUID: String, forOutput outputUID: String) { + var mappings = defaults.dictionary(forKey: preferredInputsKey) as? [String: String] ?? [:] + mappings[outputUID] = inputUID + defaults.set(mappings, forKey: preferredInputsKey) + } + + /// Clear the preferred input for a given output device + func clearPreferredInput(forOutput outputUID: String) { + var mappings = defaults.dictionary(forKey: preferredInputsKey) as? [String: String] ?? [:] + mappings.removeValue(forKey: outputUID) + defaults.set(mappings, forKey: preferredInputsKey) + } + func isHidden(_ device: AudioDevice) -> Bool { let key = hiddenKey(for: device) let hidden = defaults.array(forKey: key) as? [String] ?? [] diff --git a/AudioPriorityBar/Views/DeviceListView.swift b/AudioPriorityBar/Views/DeviceListView.swift index 4077372..9fabd87 100644 --- a/AudioPriorityBar/Views/DeviceListView.swift +++ b/AudioPriorityBar/Views/DeviceListView.swift @@ -281,6 +281,26 @@ struct DraggableDeviceRow: View { } } + // Prefer for current output (input devices only) + if device.type == .input && device.isConnected, let currentOutputId = audioManager.currentOutputId, + let currentOutput = (audioManager.speakerDevices + audioManager.headphoneDevices).first(where: { $0.id == currentOutputId }) { + Divider() + let isPreferred = audioManager.getPreferredInputUID(forOutput: currentOutput.uid) == device.uid + Button { + if isPreferred { + audioManager.clearPreferredInput(forOutput: currentOutput.uid) + } else { + audioManager.setPreferredInput(device, forOutput: currentOutput.uid) + } + } label: { + if isPreferred { + Label("Clear preferred for \(currentOutput.name)", systemImage: "star.slash") + } else { + Label("Prefer for \(currentOutput.name)", systemImage: "star") + } + } + } + if isDisconnected { Divider() Button(role: .destructive) { From 6c74742b095dcba928594f22327ec599f7d9b8f8 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Tue, 20 Jan 2026 15:40:37 -0800 Subject: [PATCH 4/6] Add fast switching --- AudioPriorityBar.xcodeproj/project.pbxproj | 4 + AudioPriorityBar/AudioPriorityBarApp.swift | 168 ++++++++++++++++-- .../Services/MenuBarController.swift | 164 +++++++++++++++++ .../Services/PriorityManager.swift | 6 + AudioPriorityBar/Views/PreferencesView.swift | 34 ++++ 5 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 AudioPriorityBar/Services/MenuBarController.swift diff --git a/AudioPriorityBar.xcodeproj/project.pbxproj b/AudioPriorityBar.xcodeproj/project.pbxproj index f7ac655..69e8247 100644 --- a/AudioPriorityBar.xcodeproj/project.pbxproj +++ b/AudioPriorityBar.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ A10000000000000000000002 /* AudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AudioDevice.swift */; }; A10000000000000000000003 /* AudioDeviceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* AudioDeviceService.swift */; }; A10000000000000000000004 /* PriorityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* PriorityManager.swift */; }; + A10000000000000000000011 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* MenuBarController.swift */; }; A10000000000000000000005 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MenuBarView.swift */; }; A10000000000000000000006 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* DeviceListView.swift */; }; A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* LaunchAtLoginManager.swift */; }; @@ -27,6 +28,7 @@ A20000000000000000000002 /* AudioDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDevice.swift; sourceTree = ""; }; A20000000000000000000003 /* AudioDeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceService.swift; sourceTree = ""; }; A20000000000000000000004 /* PriorityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriorityManager.swift; sourceTree = ""; }; + A20000000000000000000011 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; A20000000000000000000005 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; A20000000000000000000006 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = ""; }; A20000000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -91,6 +93,7 @@ A20000000000000000000004 /* PriorityManager.swift */, A20000000000000000000008 /* LaunchAtLoginManager.swift */, DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */, + A20000000000000000000011 /* MenuBarController.swift */, ); path = Services; sourceTree = ""; @@ -201,6 +204,7 @@ A10000000000000000000006 /* DeviceListView.swift in Sources */, ED800EC86195D082D84E8604 /* PreferencesView.swift in Sources */, A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */, + A10000000000000000000011 /* MenuBarController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 2282b8b..179a0fc 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -4,23 +4,163 @@ import OSLog @main struct AudioPriorityBarApp: App { - @StateObject private var audioManager = AudioManager() + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { - MenuBarExtra { - MenuBarView() + // Return an empty scene - menu bar is handled by AppDelegate + Settings { + EmptyView() + } + } +} + +class AppDelegate: NSObject, NSApplicationDelegate { + var audioManager: AudioManager! + var menuBarController: MenuBarController? + var statusItem: NSStatusItem? + var popover: NSPopover? + + @MainActor + func applicationDidFinishLaunching(_ notification: Notification) { + // Hide the app from the Dock + NSApp.setActivationPolicy(.accessory) + + // Initialize audio manager + audioManager = AudioManager() + + // Setup menu bar based on preference + if audioManager.priorityManager.isQuickSwitchEnabled { + setupQuickSwitchMode() + } else { + setupNormalMode() + } + } + + @MainActor + private func setupQuickSwitchMode() { + menuBarController = MenuBarController( + audioManager: audioManager, + isQuickSwitchEnabled: true + ) + } + + @MainActor + private func setupNormalMode() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + button.action = #selector(togglePopover(_:)) + button.target = self + updateMenuBarIcon() + } + + // Create popover + let popover = NSPopover() + popover.contentViewController = NSHostingController( + rootView: MenuBarView() .environmentObject(audioManager) - } label: { - MenuBarLabel( - volume: audioManager.volume, - isOutputMuted: audioManager.isActiveOutputMuted, - isInputMuted: audioManager.isActiveInputMuted, - isCustomMode: audioManager.isCustomMode, - mode: audioManager.currentMode, - micFlash: audioManager.micFlashState - ) - } - .menuBarExtraStyle(.window) + ) + popover.behavior = .transient + self.popover = popover + + // Observe changes to update the menu bar icon + setupObservers() + } + + @objc private func togglePopover(_ sender: AnyObject?) { + guard let popover = popover else { return } + + if popover.isShown { + popover.performClose(sender) + } else if let button = statusItem?.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + popover.contentViewController?.view.window?.makeKey() + } + } + + @MainActor + private func updateMenuBarIcon() { + guard let button = statusItem?.button else { return } + + // Build icon using SF Symbols based on state + let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + var symbols: [NSImage] = [] + + // Input mute indicator + if audioManager.isActiveInputMuted { + let micIcon = audioManager.micFlashState ? "mic.fill" : "mic.slash.fill" + if let image = NSImage(systemSymbolName: micIcon, accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Custom mode indicator + if audioManager.isCustomMode { + if let image = NSImage(systemSymbolName: "hand.raised.fill", accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Output indicator - headphone in headphone mode, speaker in speaker mode + if audioManager.currentMode == .headphone { + // Headphone icon + if let image = NSImage(systemSymbolName: "headphones", accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } else { + // Speaker icon with volume indication + let speakerIcon: String + if audioManager.isActiveOutputMuted { + speakerIcon = "speaker.slash.fill" + } else { + // Use variable speaker icon based on volume + let volume = audioManager.volume + if volume > 0.66 { + speakerIcon = "speaker.wave.3.fill" + } else if volume > 0.33 { + speakerIcon = "speaker.wave.2.fill" + } else if volume > 0 { + speakerIcon = "speaker.wave.1.fill" + } else { + speakerIcon = "speaker.fill" + } + } + + if let image = NSImage(systemSymbolName: speakerIcon, accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Combine symbols into one image + if !symbols.isEmpty { + let spacing: CGFloat = 2 + let totalWidth = symbols.map { $0.size.width }.reduce(0, +) + CGFloat(symbols.count - 1) * spacing + let maxHeight = symbols.map { $0.size.height }.max() ?? 16 + + let combinedImage = NSImage(size: NSSize(width: totalWidth, height: maxHeight)) + combinedImage.lockFocus() + + var xOffset: CGFloat = 0 + for symbol in symbols { + let yOffset = (maxHeight - symbol.size.height) / 2 + symbol.draw(at: NSPoint(x: xOffset, y: yOffset), from: .zero, operation: .sourceOver, fraction: 1.0) + xOffset += symbol.size.width + spacing + } + + combinedImage.unlockFocus() + combinedImage.isTemplate = true + button.image = combinedImage + } + } + + private func setupObservers() { + // Observe audio manager properties to update menu bar icon + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateMenuBarIcon() + } + } } } diff --git a/AudioPriorityBar/Services/MenuBarController.swift b/AudioPriorityBar/Services/MenuBarController.swift new file mode 100644 index 0000000..979f837 --- /dev/null +++ b/AudioPriorityBar/Services/MenuBarController.swift @@ -0,0 +1,164 @@ +import AppKit +import SwiftUI + +/// Controls the menu bar item and handles click events +@MainActor +class MenuBarController { + private var statusItem: NSStatusItem? + private var popover: NSPopover? + private let audioManager: AudioManager + private let isQuickSwitchEnabled: Bool + private var updateTimer: Timer? + + init(audioManager: AudioManager, isQuickSwitchEnabled: Bool) { + self.audioManager = audioManager + self.isQuickSwitchEnabled = isQuickSwitchEnabled + setupMenuBar() + setupUpdateTimer() + } + + private func setupMenuBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + updateButton() + + if isQuickSwitchEnabled { + // Quick switch mode: left click toggles, right click shows menu + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.action = #selector(handleClick(_:)) + button.target = self + } else { + // Normal mode: any click shows menu + button.action = #selector(handleClick(_:)) + button.target = self + } + } + + // Create popover for menu content + let popover = NSPopover() + popover.contentViewController = NSHostingController( + rootView: MenuBarView() + .environmentObject(audioManager) + ) + popover.behavior = .transient + self.popover = popover + } + + private func setupUpdateTimer() { + // Update the menu bar icon periodically to reflect state changes + updateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateButton() + } + } + } + + @objc private func handleClick(_ sender: NSStatusBarButton) { + guard let event = NSApp.currentEvent else { return } + + if isQuickSwitchEnabled { + if event.type == .rightMouseUp { + // Right click: show menu + showPopover(sender) + } else if event.type == .leftMouseUp { + // Left click: toggle mode + audioManager.toggleMode() + updateButton() + } + } else { + // Normal mode: show menu + showPopover(sender) + } + } + + private func showPopover(_ sender: NSStatusBarButton) { + guard let popover = popover else { return } + + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + popover.contentViewController?.view.window?.makeKey() + } + } + + func updateButton() { + guard let button = statusItem?.button else { return } + + // Build icon using SF Symbols based on state + let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + var symbols: [NSImage] = [] + + // Input mute indicator + if audioManager.isActiveInputMuted { + let micIcon = audioManager.micFlashState ? "mic.fill" : "mic.slash.fill" + if let image = NSImage(systemSymbolName: micIcon, accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Custom mode indicator + if audioManager.isCustomMode { + if let image = NSImage(systemSymbolName: "hand.raised.fill", accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Output indicator - headphone in headphone mode, speaker in speaker mode + if audioManager.currentMode == .headphone { + // Headphone icon + if let image = NSImage(systemSymbolName: "headphones", accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } else { + // Speaker icon with volume indication + let speakerIcon: String + if audioManager.isActiveOutputMuted { + speakerIcon = "speaker.slash.fill" + } else { + // Use variable speaker icon based on volume + let volume = audioManager.volume + if volume > 0.66 { + speakerIcon = "speaker.wave.3.fill" + } else if volume > 0.33 { + speakerIcon = "speaker.wave.2.fill" + } else if volume > 0 { + speakerIcon = "speaker.wave.1.fill" + } else { + speakerIcon = "speaker.fill" + } + } + + if let image = NSImage(systemSymbolName: speakerIcon, accessibilityDescription: nil)?.withSymbolConfiguration(config) { + symbols.append(image) + } + } + + // Combine symbols into one image + if !symbols.isEmpty { + let spacing: CGFloat = 2 + let totalWidth = symbols.map { $0.size.width }.reduce(0, +) + CGFloat(symbols.count - 1) * spacing + let maxHeight = symbols.map { $0.size.height }.max() ?? 16 + + let combinedImage = NSImage(size: NSSize(width: totalWidth, height: maxHeight)) + combinedImage.lockFocus() + + var xOffset: CGFloat = 0 + for symbol in symbols { + let yOffset = (maxHeight - symbol.size.height) / 2 + symbol.draw(at: NSPoint(x: xOffset, y: yOffset), from: .zero, operation: .sourceOver, fraction: 1.0) + xOffset += symbol.size.width + spacing + } + + combinedImage.unlockFocus() + combinedImage.isTemplate = true + button.image = combinedImage + } + } + + deinit { + updateTimer?.invalidate() + } +} diff --git a/AudioPriorityBar/Services/PriorityManager.swift b/AudioPriorityBar/Services/PriorityManager.swift index a56fa66..0f2004b 100644 --- a/AudioPriorityBar/Services/PriorityManager.swift +++ b/AudioPriorityBar/Services/PriorityManager.swift @@ -42,6 +42,7 @@ class PriorityManager { private let customModeKey = "customMode" private let hiddenDevicesKey = "hiddenDevices" private let knownDevicesKey = "knownDevices" + private let quickSwitchKey = "quickSwitchEnabled" // MARK: - Known Devices (Persistent Memory) @@ -101,6 +102,11 @@ class PriorityManager { set { defaults.set(newValue, forKey: customModeKey) } } + var isQuickSwitchEnabled: Bool { + get { defaults.bool(forKey: quickSwitchKey) } + set { defaults.set(newValue, forKey: quickSwitchKey) } + } + // MARK: - Device Categories func getCategory(for device: AudioDevice) -> OutputCategory { diff --git a/AudioPriorityBar/Views/PreferencesView.swift b/AudioPriorityBar/Views/PreferencesView.swift index 89a290f..09dcd00 100644 --- a/AudioPriorityBar/Views/PreferencesView.swift +++ b/AudioPriorityBar/Views/PreferencesView.swift @@ -45,6 +45,7 @@ struct PreferencesView: View { /// General preferences tab struct GeneralPreferencesTab: View { @StateObject private var launchManager = LaunchAtLoginManager.shared + @EnvironmentObject var audioManager: AudioManager var body: some View { ScrollView { @@ -72,6 +73,39 @@ struct GeneralPreferencesTab: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) + // Quick Switch section + GroupBox { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: Binding( + get: { audioManager.priorityManager.isQuickSwitchEnabled }, + set: { audioManager.priorityManager.isQuickSwitchEnabled = $0 } + )) { + VStack(alignment: .leading, spacing: 4) { + Text("Quick Switch Mode") + .font(.system(size: 13, weight: .medium)) + Text("Single-click the menu bar icon to toggle between speakers and headphones. Right-click to open the menu.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + + if audioManager.priorityManager.isQuickSwitchEnabled { + Text("Restart the app for this change to take effect.") + .font(.system(size: 11)) + .foregroundColor(.orange) + .padding(.top, 4) + } + } + .padding(12) + } label: { + Label("Menu Bar", systemImage: "hand.point.up.left.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) + // Auto-switching section GroupBox { VStack(alignment: .leading, spacing: 8) { From 129315876cb766b6c06a2c49bb1234768f089859 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Tue, 20 Jan 2026 15:56:52 -0800 Subject: [PATCH 5/6] Switch system alerts --- AudioPriorityBar/AudioPriorityBarApp.swift | 2 +- .../Services/AudioDeviceService.swift | 17 ++++++++-- .../Services/PriorityManager.swift | 12 +++++++ AudioPriorityBar/Views/PreferencesView.swift | 33 ++++++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 179a0fc..4b74a37 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -579,7 +579,7 @@ class AudioManager: ObservableObject { return } - deviceService.setDefaultDevice(device.id, type: .output) + deviceService.setDefaultDevice(device.id, type: .output, syncSystemOutput: priorityManager.syncSystemOutput) currentOutputId = device.id logger.debug(" ✅ Output device set") diff --git a/AudioPriorityBar/Services/AudioDeviceService.swift b/AudioPriorityBar/Services/AudioDeviceService.swift index 4176ccd..e2c5704 100644 --- a/AudioPriorityBar/Services/AudioDeviceService.swift +++ b/AudioPriorityBar/Services/AudioDeviceService.swift @@ -89,13 +89,26 @@ class AudioDeviceService { return status == noErr ? deviceId : nil } - func setDefaultDevice(_ deviceId: AudioObjectID, type: AudioDeviceType) { + func setDefaultDevice(_ deviceId: AudioObjectID, type: AudioDeviceType, syncSystemOutput: Bool = true) { let selector: AudioObjectPropertySelector = type == .input ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice logger.debug("📍 setDefaultDevice called - type: \(type == .input ? "input" : "output"), deviceId: \(deviceId)") + // Set the default device + setAudioDevice(deviceId, selector: selector) + + // Sync system output if this is an output device and the preference is enabled + if type == .output && syncSystemOutput { + setAudioDevice(deviceId, selector: kAudioHardwarePropertyDefaultSystemOutputDevice) + logger.debug("🔊 Synced system output to match default output") + } + + logger.debug("✅ setDefaultDevice completed - type: \(type == .input ? "input" : "output"), deviceId: \(deviceId)") + } + + private func setAudioDevice(_ deviceId: AudioObjectID, selector: AudioObjectPropertySelector) { var propertyAddress = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, @@ -113,8 +126,6 @@ class AudioDeviceService { dataSize, &mutableDeviceId ) - - logger.debug("✅ setDefaultDevice completed - type: \(type == .input ? "input" : "output"), deviceId: \(deviceId)") } func getOutputVolume() -> Float { diff --git a/AudioPriorityBar/Services/PriorityManager.swift b/AudioPriorityBar/Services/PriorityManager.swift index 0f2004b..126eec5 100644 --- a/AudioPriorityBar/Services/PriorityManager.swift +++ b/AudioPriorityBar/Services/PriorityManager.swift @@ -43,6 +43,7 @@ class PriorityManager { private let hiddenDevicesKey = "hiddenDevices" private let knownDevicesKey = "knownDevices" private let quickSwitchKey = "quickSwitchEnabled" + private let syncSystemOutputKey = "syncSystemOutput" // MARK: - Known Devices (Persistent Memory) @@ -107,6 +108,17 @@ class PriorityManager { set { defaults.set(newValue, forKey: quickSwitchKey) } } + var syncSystemOutput: Bool { + get { + // Default to true if not set + if defaults.object(forKey: syncSystemOutputKey) == nil { + return true + } + return defaults.bool(forKey: syncSystemOutputKey) + } + set { defaults.set(newValue, forKey: syncSystemOutputKey) } + } + // MARK: - Device Categories func getCategory(for device: AudioDevice) -> OutputCategory { diff --git a/AudioPriorityBar/Views/PreferencesView.swift b/AudioPriorityBar/Views/PreferencesView.swift index 09dcd00..a5a3639 100644 --- a/AudioPriorityBar/Views/PreferencesView.swift +++ b/AudioPriorityBar/Views/PreferencesView.swift @@ -106,6 +106,37 @@ struct GeneralPreferencesTab: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) + // System Output Sync section + GroupBox { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: Binding( + get: { audioManager.priorityManager.syncSystemOutput }, + set: { audioManager.priorityManager.syncSystemOutput = $0 } + )) { + VStack(alignment: .leading, spacing: 4) { + Text("Sync System Sound Effects Output") + .font(.system(size: 13, weight: .medium)) + Text("Automatically update \"Play sound effects through\" in System Settings to match the selected output device.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + + Text("⚠️ If you disable this app, you may need to manually reset the system sound effects output in System Settings.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.top, 4) + } + .padding(12) + } label: { + Label("System Audio", systemImage: "speaker.wave.2.circle") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) + // Auto-switching section GroupBox { VStack(alignment: .leading, spacing: 8) { @@ -178,7 +209,7 @@ struct AboutPreferencesTab: View { // GitHub link VStack(spacing: 10) { Button { - if let url = URL(string: "https://github.com/johanhenkens/AudioPriorityBar") { + if let url = URL(string: "https://github.com/jhenkens/AudioPriorityBar") { NSWorkspace.shared.open(url) } } label: { From fd1db1e36a742aaab88abf2df380fc249e3d6df5 Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Thu, 29 Jan 2026 13:31:56 -0800 Subject: [PATCH 6/6] Fix mute and no-headphone bugs --- AudioPriorityBar/AudioPriorityBarApp.swift | 29 ++++- .../Services/AudioDeviceService.swift | 37 +++++++ AudioPriorityBar/Views/MenuBarView.swift | 20 +++- README.md | 103 +++++++++++++----- 4 files changed, 155 insertions(+), 34 deletions(-) diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 4b74a37..bc9f44d 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -302,6 +302,25 @@ class AudioManager: ObservableObject { func setVolume(_ newVolume: Float) { volume = newVolume deviceService.setOutputVolume(newVolume) + + // Unmute the device if it's muted and volume is being changed + if let outputId = currentOutputId, isActiveOutputMuted { + deviceService.setDeviceMuted(outputId, type: .output, muted: false) + // Refresh mute status immediately + Task { @MainActor in + self.refreshMuteStatus() + } + } + } + + func toggleOutputMute() { + guard let outputId = currentOutputId else { return } + let newMuteState = !isActiveOutputMuted + deviceService.setDeviceMuted(outputId, type: .output, muted: newMuteState) + // Refresh mute status immediately + Task { @MainActor in + self.refreshMuteStatus() + } } var activeOutputDevices: [AudioDevice] { @@ -419,7 +438,15 @@ class AudioManager: ObservableObject { func toggleMode() { let newMode: OutputCategory = currentMode == .speaker ? .headphone : .speaker - setMode(newMode) + + // Check if target mode has any connected devices + let targetDevices = newMode == .speaker ? speakerDevices : headphoneDevices + let hasConnectedDevices = targetDevices.contains { $0.isConnected } + + // Only switch if target mode has connected devices + if hasConnectedDevices { + setMode(newMode) + } } func setCustomMode(_ enabled: Bool) { diff --git a/AudioPriorityBar/Services/AudioDeviceService.swift b/AudioPriorityBar/Services/AudioDeviceService.swift index e2c5704..1b1f8ff 100644 --- a/AudioPriorityBar/Services/AudioDeviceService.swift +++ b/AudioPriorityBar/Services/AudioDeviceService.swift @@ -228,6 +228,43 @@ class AudioDeviceService { return false } + func setDeviceMuted(_ deviceId: AudioObjectID, type: AudioDeviceType, muted: Bool) { + let scope: AudioObjectPropertyScope = type == .input + ? kAudioDevicePropertyScopeInput + : kAudioDevicePropertyScopeOutput + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + + var muteValue: UInt32 = muted ? 1 : 0 + let dataSize = UInt32(MemoryLayout.size) + + var status = AudioObjectSetPropertyData( + deviceId, + &propertyAddress, + 0, + nil, + dataSize, + &muteValue + ) + + // If master element didn't work, try element 1 (first channel) + if status != noErr { + propertyAddress.mElement = 1 + AudioObjectSetPropertyData( + deviceId, + &propertyAddress, + 0, + nil, + dataSize, + &muteValue + ) + } + } + func getDeviceVolume(_ deviceId: AudioObjectID) -> Float { var propertyAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, diff --git a/AudioPriorityBar/Views/MenuBarView.swift b/AudioPriorityBar/Views/MenuBarView.swift index b89dd01..20b1d93 100644 --- a/AudioPriorityBar/Views/MenuBarView.swift +++ b/AudioPriorityBar/Views/MenuBarView.swift @@ -214,7 +214,9 @@ struct VolumeSliderView: View { @EnvironmentObject var audioManager: AudioManager var volumeIcon: String { - if audioManager.currentMode == .headphone { + if audioManager.isActiveOutputMuted { + return "speaker.slash.fill" + } else if audioManager.currentMode == .headphone { return "headphones" } else { if audioManager.volume <= 0 { @@ -231,11 +233,17 @@ struct VolumeSliderView: View { var body: some View { HStack(spacing: 10) { - Image(systemName: volumeIcon) - .font(.system(size: 13)) - .foregroundColor(.accentColor) - .frame(width: 20) - .animation(.easeInOut(duration: 0.15), value: volumeIcon) + Button { + audioManager.toggleOutputMute() + } label: { + Image(systemName: volumeIcon) + .font(.system(size: 13)) + .foregroundColor(.accentColor) + .frame(width: 20) + .animation(.easeInOut(duration: 0.15), value: volumeIcon) + } + .buttonStyle(.plain) + .help(audioManager.isActiveOutputMuted ? "Unmute" : "Mute") Slider( value: Binding( diff --git a/README.md b/README.md index eea547b..4e2bf13 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Audio Priority Bar Icon

-A native macOS menu bar app that automatically manages audio device priorities. Set your preferred order for speakers, headphones, and microphones - the app automatically switches to the highest-priority connected device. +A native macOS menu bar app that intelligently manages your audio devices. Organize speakers, headphones, and microphones by priority, and let the app automatically switch to your preferred device when you plug it in. Perfect for users who frequently switch between multiple audio setups. ![macOS 13+](https://img.shields.io/badge/macOS-13%2B-blue) ![Swift](https://img.shields.io/badge/Swift-5.9-orange) @@ -14,14 +14,28 @@ A native macOS menu bar app that automatically manages audio device priorities. ## Features -- **Priority-based auto-switching**: Devices are ranked by priority. When a higher-priority device connects, it automatically becomes active. -- **Separate speaker/headphone modes**: Output devices are categorized as either speakers or headphones, each with their own priority list. -- **Manual override**: Enable "Custom" mode (hand icon) to disable auto-switching and select devices freely. -- **Device memory**: Remembers all devices you've ever connected, even when disconnected. Edit mode shows disconnected devices with "last seen" timestamps. -- **Per-category ignore**: Hide devices from specific categories without affecting others. +### Smart Audio Management +- **Priority-based auto-switching**: Rank your devices by preference. When you connect a higher-priority device, it automatically becomes active - no manual switching needed. +- **Dual-mode operation**: Separate priority lists for speakers and headphones. Switch modes manually or enable quick-switch for one-click toggling. +- **Fast switching**: Enable quick-switch mode in preferences to toggle between speakers and headphones with a single left-click on the menu bar icon. Only switches when devices are available in the target mode. +- **Manual mode**: Toggle "Custom" mode (✋ hand icon) to disable auto-switching and control devices manually. +- **Persistent device memory**: Remembers every device you've connected, even when unplugged. Maintains priority order and preferences across reconnections. + +### Volume & Mute Controls +- **Integrated volume control**: Adjust output volume via slider, scroll wheel, or system keys. +- **Smart mute handling**: Click the volume icon to mute/unmute. Adjusting volume automatically unmutes if currently muted. +- **Visual feedback**: Menu bar icon shows current mode, volume level, mute status, and microphone state. + +### Device Organization - **Drag-to-reorder**: Reorder devices by dragging or using up/down arrows. -- **Volume control**: Adjust volume with slider or scroll wheel. -- **Menu bar integration**: Shows current mode icon and volume percentage. +- **Per-category customization**: Assign output devices to speaker or headphone categories. Hide devices from specific categories without affecting others. +- **Device filtering**: Hide devices you don't use or mark them as "never use" to exclude them from auto-switching. +- **Preferred input pairing**: Assign specific microphones to specific output devices for automatic switching. + +### Edit Mode & History +- **Comprehensive edit mode**: See all devices ever connected, including disconnected ones (shown grayed out). +- **Timestamp tracking**: View "last seen" timestamps for disconnected devices. +- **Device management**: Forget old devices you no longer use to keep your lists clean. ## Installation @@ -50,41 +64,76 @@ Check the [Releases](https://github.com/tobi/AudioPriorityBar/releases) page for ## Usage -### Modes +### Operating Modes | Mode | Icon | Behavior | |------|------|----------| | **Speakers** | 🔊 | Shows speaker devices, auto-switches to highest priority | | **Headphones** | 🎧 | Shows headphone devices, auto-switches to highest priority | -| **Custom** | ✋ | Shows all devices, no auto-switching | +| **Custom** | ✋ | Shows all devices, disables auto-switching for manual control | + +### Quick Access (Menu Bar Icon) + +- **Left-click**: Open main menu (default) or toggle speaker/headphone mode (if quick-switch enabled in preferences) +- **Right-click**: Always opens main menu when quick-switch is enabled +- **Icon display**: Shows current mode icon, volume level, mute status (🔇), and microphone mute indicator + +### Volume & Mute + +- **Volume slider**: Drag to adjust output volume +- **Scroll wheel**: Hover over volume slider and scroll to adjust +- **Volume icon**: Click to toggle mute/unmute +- **Smart unmute**: Adjusting volume when muted automatically unmutes -### Managing Priorities +### Managing Device Priorities -- **Click a device**: Moves it to #1 priority (in normal mode) or just selects it (in custom mode) -- **Drag devices**: Reorder by dragging the handle -- **Up/Down arrows**: Fine-tune order on hover +- **Click a device**: Makes it active and moves it to #1 priority (in auto-switch modes) or just selects it (in custom mode) +- **Drag to reorder**: Grab the handle (≡) and drag devices up/down +- **Arrow controls**: Hover over a device to reveal up/down arrows for fine-tuning order +- **Reordering behavior**: In speaker/headphone modes, moving a device to #1 automatically activates it -### Device Actions (hover menu) +### Device Actions (Edit Mode Menu) -- **Move to Speakers/Headphones**: Change device category -- **Ignore as [category]**: Hide from current category only -- **Ignore entirely**: Hide from both speaker and headphone lists -- **Forget Device**: Remove disconnected device from memory +- **Move to Speakers/Headphones**: Re-categorize output devices +- **Ignore as [category]**: Hide device from current category only +- **Ignore entirely**: Hide from both speaker and headphone categories +- **Never use**: Exclude device from auto-switching but keep it visible +- **Set preferred input**: Pair a specific microphone with an output device +- **Forget device**: Remove disconnected device from app memory ### Edit Mode -Click "Edit" in the footer to: -- See all devices ever connected (disconnected ones grayed out) -- Reorder disconnected devices in the priority list -- View "last seen" timestamps +Click **Edit** in the footer to access advanced features: +- View all devices ever connected, including disconnected ones (grayed out with timestamps) +- Reorder disconnected devices to set their priority for when they reconnect +- Manage hidden/ignored devices - Forget old devices you no longer use +- Click **Done** to return to normal view + +### Preferences + +Click the **⚙️ gear icon** in the footer to access preferences: + +**Startup** +- **Launch at Login**: Automatically start Audio Priority Bar when you log in to macOS + +**Menu Bar** +- **Quick Switch Mode**: Enable single-click toggle between speakers/headphones (requires app restart) + +**System Audio** +- **Sync System Sound Effects Output**: Automatically update macOS system sound effects output to match selected device + +**Auto-Switching** +- View information about auto-switching behavior and mode controls ## How It Works -1. **Device Discovery**: Uses CoreAudio to enumerate audio devices and listen for changes. -2. **Priority Storage**: Device priorities are stored in UserDefaults, keyed by device UID (stable across reconnects). -3. **Auto-Switching**: When devices connect/disconnect, the app automatically selects the highest-priority available device for the current mode. -4. **Categories**: Each output device is assigned to either "speaker" or "headphone" category, with separate priority lists. +1. **Device Discovery**: Uses CoreAudio to enumerate audio devices and listen for hardware changes in real-time. +2. **Priority Storage**: Device priorities, categories, and preferences are stored in UserDefaults, keyed by device UID (stable across reconnects). +3. **Smart Auto-Switching**: When devices connect/disconnect, the app automatically selects the highest-priority available device for the current mode, while avoiding switching loops. +4. **Category System**: Each output device is assigned to either "speaker" or "headphone" category, each with independent priority lists and visibility settings. +5. **Mute & Volume Management**: Volume and mute state are managed through CoreAudio's VirtualMainVolume property. The app automatically unmutes when volume is adjusted and prevents fast-switching to unavailable device categories. +6. **Preferred Input Pairing**: Output devices can be paired with specific input devices for automatic microphone switching based on audio output. ## Project Structure