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..69e8247 100644 --- a/AudioPriorityBar.xcodeproj/project.pbxproj +++ b/AudioPriorityBar.xcodeproj/project.pbxproj @@ -11,12 +11,16 @@ 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 */; }; 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 */ @@ -24,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 = ""; }; @@ -32,6 +37,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 +81,7 @@ children = ( A20000000000000000000002 /* AudioDevice.swift */, A20000000000000000000009 /* Headphones.swift */, + 622640DCAA11DD5022FC5DBF /* AppInfo.swift */, ); path = Models; sourceTree = ""; @@ -83,6 +92,8 @@ A20000000000000000000003 /* AudioDeviceService.swift */, A20000000000000000000004 /* PriorityManager.swift */, A20000000000000000000008 /* LaunchAtLoginManager.swift */, + DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */, + A20000000000000000000011 /* MenuBarController.swift */, ); path = Services; sourceTree = ""; @@ -92,6 +103,7 @@ children = ( A20000000000000000000005 /* MenuBarView.swift */, A20000000000000000000006 /* DeviceListView.swift */, + BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */, ); path = Views; sourceTree = ""; @@ -184,11 +196,15 @@ 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 */, + A10000000000000000000011 /* MenuBarController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 3f455e9..bc9f44d 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -1,18 +1,166 @@ import SwiftUI import CoreAudio +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: { - Image(systemName: "speaker.wave.2.fill") + ) + 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() + } } - .menuBarExtraStyle(.window) } } @@ -93,6 +241,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 } @@ -149,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] { @@ -266,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) { @@ -350,6 +530,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) @@ -386,13 +578,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) { - deviceService.setDefaultDevice(device.id, type: .output) + 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, syncSystemOutput: priorityManager.syncSystemOutput) 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() { @@ -410,29 +633,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/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/AudioDeviceService.swift b/AudioPriorityBar/Services/AudioDeviceService.swift index 43431fe..1b1f8ff 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, @@ -82,11 +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, @@ -206,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, @@ -229,26 +288,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 +337,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 +350,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 +476,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/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/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/Services/PriorityManager.swift b/AudioPriorityBar/Services/PriorityManager.swift index 337dc2f..126eec5 100644 --- a/AudioPriorityBar/Services/PriorityManager.swift +++ b/AudioPriorityBar/Services/PriorityManager.swift @@ -42,6 +42,8 @@ class PriorityManager { private let customModeKey = "customMode" private let hiddenDevicesKey = "hiddenDevices" private let knownDevicesKey = "knownDevices" + private let quickSwitchKey = "quickSwitchEnabled" + private let syncSystemOutputKey = "syncSystemOutput" // MARK: - Known Devices (Persistent Memory) @@ -101,6 +103,22 @@ class PriorityManager { set { defaults.set(newValue, forKey: customModeKey) } } + var isQuickSwitchEnabled: Bool { + get { defaults.bool(forKey: quickSwitchKey) } + 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 { @@ -148,6 +166,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) { diff --git a/AudioPriorityBar/Views/MenuBarView.swift b/AudioPriorityBar/Views/MenuBarView.swift index 3ad55c2..20b1d93 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 { @@ -206,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 { @@ -223,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/AudioPriorityBar/Views/PreferencesView.swift b/AudioPriorityBar/Views/PreferencesView.swift new file mode 100644 index 0000000..a5a3639 --- /dev/null +++ b/AudioPriorityBar/Views/PreferencesView.swift @@ -0,0 +1,256 @@ +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 + @EnvironmentObject var audioManager: AudioManager + + 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()) + + // 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()) + + // 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) { + 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/jhenkens/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)) + ) + } +} 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