diff --git a/AudioPriorityBar.xcodeproj/project.pbxproj b/AudioPriorityBar.xcodeproj/project.pbxproj index 4ed325d..6d44e0b 100644 --- a/AudioPriorityBar.xcodeproj/project.pbxproj +++ b/AudioPriorityBar.xcodeproj/project.pbxproj @@ -14,9 +14,16 @@ 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 */; }; + A10000000000000000000011 /* AdaptiveDeviceListLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* AdaptiveDeviceListLayout.swift */; }; + A10000000000000000000012 /* DefaultDeviceChangeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* DefaultDeviceChangeTracker.swift */; }; + A10000000000000000000013 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* AudioManager.swift */; }; A10000000000000000000010 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; }; A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* Assets.xcassets */; }; + A10000000000000000000030 /* PriorityManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000030 /* PriorityManagerTests.swift */; }; + A10000000000000000000031 /* AudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AudioDevice.swift */; }; + A10000000000000000000032 /* PriorityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* PriorityManager.swift */; }; + A10000000000000000000033 /* DefaultDeviceChangeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* DefaultDeviceChangeTracker.swift */; }; + A10000000000000000000034 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,10 +35,14 @@ 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 = ""; }; A20000000000000000000008 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = ""; }; - A20000000000000000000009 /* Headphones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Headphones.swift; sourceTree = ""; }; + A20000000000000000000011 /* AdaptiveDeviceListLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveDeviceListLayout.swift; sourceTree = ""; }; + A20000000000000000000012 /* DefaultDeviceChangeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDeviceChangeTracker.swift; sourceTree = ""; }; + A20000000000000000000013 /* AudioManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioManager.swift; sourceTree = ""; }; 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 = ""; }; + A20000000000000000000030 /* PriorityManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriorityManagerTests.swift; sourceTree = ""; }; A30000000000000000000001 /* AudioPriorityBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPriorityBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A30000000000000000000002 /* AudioPriorityBarTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AudioPriorityBarTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -43,6 +54,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000034 /* CoreAudio.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -50,6 +69,7 @@ isa = PBXGroup; children = ( A50000000000000000000002 /* AudioPriorityBar */, + A50000000000000000000008 /* AudioPriorityBarTests */, A50000000000000000000007 /* Frameworks */, A50000000000000000000006 /* Products */, ); @@ -72,7 +92,7 @@ isa = PBXGroup; children = ( A20000000000000000000002 /* AudioDevice.swift */, - A20000000000000000000009 /* Headphones.swift */, + A20000000000000000000012 /* DefaultDeviceChangeTracker.swift */, ); path = Models; sourceTree = ""; @@ -80,6 +100,7 @@ A50000000000000000000004 /* Services */ = { isa = PBXGroup; children = ( + A20000000000000000000013 /* AudioManager.swift */, A20000000000000000000003 /* AudioDeviceService.swift */, A20000000000000000000004 /* PriorityManager.swift */, A20000000000000000000008 /* LaunchAtLoginManager.swift */, @@ -90,6 +111,7 @@ A50000000000000000000005 /* Views */ = { isa = PBXGroup; children = ( + A20000000000000000000011 /* AdaptiveDeviceListLayout.swift */, A20000000000000000000005 /* MenuBarView.swift */, A20000000000000000000006 /* DeviceListView.swift */, ); @@ -100,6 +122,7 @@ isa = PBXGroup; children = ( A30000000000000000000001 /* AudioPriorityBar.app */, + A30000000000000000000002 /* AudioPriorityBarTests.xctest */, ); name = Products; sourceTree = ""; @@ -112,6 +135,14 @@ name = Frameworks; sourceTree = ""; }; + A50000000000000000000008 /* AudioPriorityBarTests */ = { + isa = PBXGroup; + children = ( + A20000000000000000000030 /* PriorityManagerTests.swift */, + ); + path = AudioPriorityBarTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXResourcesBuildPhase section */ @@ -143,6 +174,22 @@ productReference = A30000000000000000000001 /* AudioPriorityBar.app */; productType = "com.apple.product-type.application"; }; + A60000000000000000000002 /* AudioPriorityBarTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A80000000000000000000003 /* Build configuration list for PBXNativeTarget "AudioPriorityBarTests" */; + buildPhases = ( + A70000000000000000000002 /* Sources */, + A40000000000000000000003 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AudioPriorityBarTests; + productName = AudioPriorityBarTests; + productReference = A30000000000000000000002 /* AudioPriorityBarTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -156,6 +203,9 @@ A60000000000000000000001 = { CreatedOnToolsVersion = 15.0; }; + A60000000000000000000002 = { + CreatedOnToolsVersion = 15.0; + }; }; }; buildConfigurationList = A80000000000000000000002 /* Build configuration list for PBXProject "AudioPriorityBar" */; @@ -172,6 +222,7 @@ projectRoot = ""; targets = ( A60000000000000000000001 /* AudioPriorityBar */, + A60000000000000000000002 /* AudioPriorityBarTests */, ); }; /* End PBXProject section */ @@ -182,13 +233,26 @@ buildActionMask = 2147483647; files = ( A10000000000000000000001 /* AudioPriorityBarApp.swift in Sources */, + A10000000000000000000013 /* AudioManager.swift in Sources */, A10000000000000000000002 /* AudioDevice.swift in Sources */, - A10000000000000000000009 /* Headphones.swift in Sources */, A10000000000000000000003 /* AudioDeviceService.swift in Sources */, A10000000000000000000004 /* PriorityManager.swift in Sources */, A10000000000000000000005 /* MenuBarView.swift in Sources */, A10000000000000000000006 /* DeviceListView.swift in Sources */, A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */, + A10000000000000000000011 /* AdaptiveDeviceListLayout.swift in Sources */, + A10000000000000000000012 /* DefaultDeviceChangeTracker.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A70000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000030 /* PriorityManagerTests.swift in Sources */, + A10000000000000000000031 /* AudioDevice.swift in Sources */, + A10000000000000000000032 /* PriorityManager.swift in Sources */, + A10000000000000000000033 /* DefaultDeviceChangeTracker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -371,6 +435,40 @@ }; name = Release; }; + B30000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.audioprioritybar.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B30000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.audioprioritybar.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -392,6 +490,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A80000000000000000000003 /* Build configuration list for PBXNativeTarget "AudioPriorityBarTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B30000000000000000000001 /* Debug */, + B30000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 3f455e9..3909558 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -1,460 +1,16 @@ import SwiftUI -import CoreAudio @main struct AudioPriorityBarApp: App { @StateObject private var audioManager = AudioManager() - + var body: some Scene { MenuBarExtra { MenuBarView() .environmentObject(audioManager) } label: { - Image(systemName: "speaker.wave.2.fill") + Image(systemName: audioManager.menuBarIcon) } .menuBarExtraStyle(.window) } } - -struct MenuBarLabel: View { - let volume: Float - let isOutputMuted: Bool - let isInputMuted: Bool - let isCustomMode: Bool - let mode: OutputCategory - let micFlash: Bool - - var body: some View { - HStack(spacing: 2) { - if isInputMuted { - Image(systemName: micFlash ? "mic.fill" : "mic.slash.fill") - } - if isCustomMode { - Image(systemName: "hand.raised.fill") - } else if mode == .headphone { - Image(systemName: "headphones") - } - if isOutputMuted { - Image(systemName: "speaker.slash.fill") - } else { - Image(systemName: "speaker.wave.3.fill", variableValue: Double(volume)) - } - } - } -} - -struct VolumeMeterView: View { - let volume: Float - let isMuted: Bool - private let barCount = 4 - private let barSpacing: CGFloat = 1 - - var body: some View { - Canvas { context, size in - let barWidth = (size.width - CGFloat(barCount - 1) * barSpacing) / CGFloat(barCount) - let filledBars = isMuted ? 0 : Int(ceil(Double(volume) * Double(barCount))) - for i in 0.. = [] - @Published var isActiveOutputMuted: Bool = false - @Published var isActiveInputMuted: Bool = false - @Published var micFlashState: Bool = false - - private let deviceService = AudioDeviceService() - private var micFlashTimer: Timer? - let priorityManager = PriorityManager() - private var connectedDeviceUIDs: Set = [] - - var menuBarIcon: String { - currentMode.icon - } - - func refreshVolume() { - volume = deviceService.getOutputVolume() - } - - func refreshMuteStatus() { - var muted: Set = [] - for device in inputDevices where device.isConnected { - if deviceService.isDeviceMuted(device.id, type: .input) { - muted.insert(device.id) - } - } - for device in speakerDevices where device.isConnected { - if deviceService.isDeviceMuted(device.id, type: .output) { - muted.insert(device.id) - } - } - for device in headphoneDevices where device.isConnected { - if deviceService.isDeviceMuted(device.id, type: .output) { - muted.insert(device.id) - } - } - mutedDeviceIds = muted - if let outputId = currentOutputId { - isActiveOutputMuted = muted.contains(outputId) - } else { - isActiveOutputMuted = false - } - if let inputId = currentInputId { - isActiveInputMuted = muted.contains(inputId) - } else { - isActiveInputMuted = false - } - if isActiveInputMuted && micFlashTimer == nil { - micFlashTimer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.micFlashState.toggle() - } - } - } else if !isActiveInputMuted && micFlashTimer != nil { - micFlashTimer?.invalidate() - micFlashTimer = nil - micFlashState = false - } - } - - func isDeviceMuted(_ device: AudioDevice) -> Bool { - mutedDeviceIds.contains(device.id) - } - - func setVolume(_ newVolume: Float) { - volume = newVolume - deviceService.setOutputVolume(newVolume) - } - - var activeOutputDevices: [AudioDevice] { - switch currentMode { - case .speaker: return speakerDevices - case .headphone: return headphoneDevices - } - } - - init() { - currentMode = priorityManager.currentMode - isCustomMode = priorityManager.isCustomMode - refreshDevices() - previousConnectedUIDs = connectedDeviceUIDs // Initialize tracking - refreshVolume() - refreshMuteStatus() - setupDeviceChangeListener() - setupMuteVolumeListener() - if !isCustomMode { - applyHighestPriorityInput() - applyHighestPriorityOutput() - } - } - - private func setupMuteVolumeListener() { - deviceService.onMuteOrVolumeChanged = { [weak self] in - Task { @MainActor in - self?.handleMuteOrVolumeChange() - } - } - } - - private func handleMuteOrVolumeChange() { - refreshMuteStatus() - refreshVolume() - } - - func refreshDevices() { - let allConnectedDevices = deviceService.getDevices() - connectedDeviceUIDs = Set(allConnectedDevices.map { $0.uid }) - for device in allConnectedDevices { - priorityManager.rememberDevice(device.uid, name: device.name, isInput: device.type == .input) - } - let connectedInputs = allConnectedDevices.filter { $0.type == .input } - let connectedOutputs = allConnectedDevices.filter { $0.type == .output } - - if isEditMode { - let knownDevices = priorityManager.getKnownDevices() - var allInputs: [AudioDevice] = connectedInputs - for stored in knownDevices where stored.isInput { - if !connectedDeviceUIDs.contains(stored.uid) { - allInputs.append(.disconnected(uid: stored.uid, name: stored.name, type: .input)) - } - } - var allOutputs: [AudioDevice] = connectedOutputs - for stored in knownDevices where !stored.isInput { - if !connectedDeviceUIDs.contains(stored.uid) { - allOutputs.append(.disconnected(uid: stored.uid, name: stored.name, type: .output)) - } - } - inputDevices = priorityManager.sortByPriority(allInputs, type: .input) - hiddenInputDevices = [] - let speakers = allOutputs.filter { priorityManager.getCategory(for: $0) == .speaker } - let headphones = allOutputs.filter { priorityManager.getCategory(for: $0) == .headphone } - speakerDevices = priorityManager.sortByPriority(speakers, category: .speaker) - headphoneDevices = priorityManager.sortByPriority(headphones, category: .headphone) - hiddenSpeakerDevices = [] - hiddenHeadphoneDevices = [] - } else { - // Filter out hidden and never-use devices in normal mode - let visibleInputs = connectedInputs.filter { !priorityManager.isHidden($0) && !priorityManager.isNeverUse($0) } - // Hidden inputs: regular hidden first, then never-use - let regularHiddenInputs = connectedInputs.filter { priorityManager.isHidden($0) && !priorityManager.isNeverUse($0) } - let neverUseInputs = connectedInputs.filter { priorityManager.isNeverUse($0) } - inputDevices = priorityManager.sortByPriority(visibleInputs, type: .input) - hiddenInputDevices = regularHiddenInputs + neverUseInputs - - let speakers = connectedOutputs.filter { priorityManager.getCategory(for: $0) == .speaker } - let headphones = connectedOutputs.filter { priorityManager.getCategory(for: $0) == .headphone } - let visibleSpeakers = speakers.filter { !priorityManager.isHidden($0, inCategory: .speaker) && !priorityManager.isNeverUse($0) } - let visibleHeadphones = headphones.filter { !priorityManager.isHidden($0, inCategory: .headphone) && !priorityManager.isNeverUse($0) } - // Hidden outputs: regular hidden first, then never-use - let regularHiddenSpeakers = speakers.filter { priorityManager.isHidden($0, inCategory: .speaker) && !priorityManager.isNeverUse($0) } - let neverUseSpeakers = speakers.filter { priorityManager.isNeverUse($0) } - let regularHiddenHeadphones = headphones.filter { priorityManager.isHidden($0, inCategory: .headphone) && !priorityManager.isNeverUse($0) } - let neverUseHeadphones = headphones.filter { priorityManager.isNeverUse($0) } - speakerDevices = priorityManager.sortByPriority(visibleSpeakers, category: .speaker) - headphoneDevices = priorityManager.sortByPriority(visibleHeadphones, category: .headphone) - hiddenSpeakerDevices = regularHiddenSpeakers + neverUseSpeakers - hiddenHeadphoneDevices = regularHiddenHeadphones + neverUseHeadphones - } - currentInputId = deviceService.getCurrentDefaultDevice(type: .input) - currentOutputId = deviceService.getCurrentDefaultDevice(type: .output) - } - - func toggleEditMode() { - isEditMode.toggle() - refreshDevices() - } - - func isDeviceConnected(_ device: AudioDevice) -> Bool { - connectedDeviceUIDs.contains(device.uid) - } - - /// Tracks device UIDs from the previous refresh to detect new connections - private var previousConnectedUIDs: Set = [] - - func setMode(_ mode: OutputCategory) { - currentMode = mode - priorityManager.currentMode = mode - if !isCustomMode { - applyHighestPriorityOutput() - } - } - - func toggleMode() { - let newMode: OutputCategory = currentMode == .speaker ? .headphone : .speaker - setMode(newMode) - } - - func setCustomMode(_ enabled: Bool) { - isCustomMode = enabled - priorityManager.isCustomMode = enabled - if !enabled { - applyHighestPriorityInput() - applyHighestPriorityOutput() - } - } - - func setCategory(_ category: OutputCategory, for device: AudioDevice) { - priorityManager.setCategory(category, for: device) - refreshDevices() - if !isCustomMode { - applyHighestPriorityOutput() - } - } - - func hideDevice(_ device: AudioDevice, category: OutputCategory? = nil) { - if device.type == .input { - priorityManager.hideDevice(device) - } else if let cat = category { - priorityManager.hideDevice(device, inCategory: cat) - } else { - priorityManager.hideDevice(device) - } - refreshDevices() - if !isCustomMode { - if device.type == .input { - applyHighestPriorityInput() - } else { - applyHighestPriorityOutput() - } - } - } - - func hideDeviceEntirely(_ device: AudioDevice) { - priorityManager.hideDevice(device, inCategory: .speaker) - priorityManager.hideDevice(device, inCategory: .headphone) - refreshDevices() - if !isCustomMode { - applyHighestPriorityOutput() - } - } - - func unhideDevice(_ device: AudioDevice, category: OutputCategory? = nil) { - if device.type == .input { - priorityManager.unhideDevice(device) - } else if let cat = category { - priorityManager.unhideDevice(device, fromCategory: cat) - } else { - priorityManager.unhideDevice(device) - } - refreshDevices() - } - - func isDeviceIgnored(_ device: AudioDevice, inCategory category: OutputCategory? = nil) -> Bool { - if device.type == .input { - return priorityManager.isHidden(device) - } else if let cat = category { - return priorityManager.isHidden(device, inCategory: cat) - } else { - return priorityManager.isHidden(device) - } - } - - func isNeverUse(_ device: AudioDevice) -> Bool { - priorityManager.isNeverUse(device) - } - - func setNeverUse(_ device: AudioDevice, neverUse: Bool) { - priorityManager.setNeverUse(device, neverUse: neverUse) - refreshDevices() - if !isCustomMode { - if device.type == .input { - applyHighestPriorityInput() - } else { - applyHighestPriorityOutput() - } - } - } - - func moveInputDevice(from source: IndexSet, to destination: Int) { - inputDevices.move(fromOffsets: source, toOffset: destination) - priorityManager.savePriorities(inputDevices, type: .input) - // Switch to top input if it's connected - if let topInput = inputDevices.first, topInput.isConnected { - applyInputDevice(topInput) - } - } - - func moveSpeakerDevice(from source: IndexSet, to destination: Int) { - speakerDevices.move(fromOffsets: source, toOffset: destination) - priorityManager.savePriorities(speakerDevices, category: .speaker) - // Switch to top speaker only if we're in speaker mode and top speaker is connected - if currentMode == .speaker, let topSpeaker = speakerDevices.first, topSpeaker.isConnected { - applyOutputDevice(topSpeaker) - } - } - - func moveHeadphoneDevice(from source: IndexSet, to destination: Int) { - headphoneDevices.move(fromOffsets: source, toOffset: destination) - priorityManager.savePriorities(headphoneDevices, category: .headphone) - // Switch to top headphone if it's connected - if let topHeadphone = headphoneDevices.first, topHeadphone.isConnected { - applyOutputDevice(topHeadphone) - } - } - - func setInputDevice(_ device: AudioDevice) { - applyInputDevice(device) - } - - func setOutputDevice(_ device: AudioDevice) { - applyOutputDevice(device) - } - - private func applyInputDevice(_ device: AudioDevice) { - deviceService.setDefaultDevice(device.id, type: .input) - currentInputId = device.id - } - - private func applyOutputDevice(_ device: AudioDevice) { - deviceService.setDefaultDevice(device.id, type: .output) - currentOutputId = device.id - } - - private func applyHighestPriorityInput() { - if let first = inputDevices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) { - applyInputDevice(first) - } - } - - private func applyHighestPriorityOutput() { - let devices = activeOutputDevices - if let first = devices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) { - applyOutputDevice(first) - } - refreshMuteStatus() - } - - private func setupDeviceChangeListener() { - deviceService.onDevicesChanged = { [weak self] in - Task { @MainActor in - self?.handleDeviceChange() - } - } - deviceService.startListening() - } - - private func handleDeviceChange() { - let oldConnectedUIDs = previousConnectedUIDs - refreshDevices() - refreshMuteStatus() - - // Detect newly connected devices - let newlyConnectedUIDs = connectedDeviceUIDs.subtracting(oldConnectedUIDs) - previousConnectedUIDs = connectedDeviceUIDs - - if !isCustomMode { - // Auto-switch mode only when a new headphone connects or all headphones disconnect - autoSwitchModeIfNeeded(newlyConnectedUIDs: newlyConnectedUIDs) - applyHighestPriorityInput() - applyHighestPriorityOutput() - } - } - - /// Automatically switches between headphone and speaker mode based on device connections. - /// Only triggers on: - /// 1. A new headphone device connects → switch to headphone mode - /// 2. All headphones disconnect → switch to speaker mode - private func autoSwitchModeIfNeeded(newlyConnectedUIDs: Set) { - let connectedHeadphones = headphoneDevices.filter { $0.isConnected } - let hasConnectedHeadphones = !connectedHeadphones.isEmpty - let hasConnectedSpeakers = speakerDevices.contains { $0.isConnected } - - // Check if a new headphone just connected - let newHeadphoneConnected = connectedHeadphones.contains { newlyConnectedUIDs.contains($0.uid) } - - if newHeadphoneConnected && currentMode != .headphone { - // A new headphone just connected - switch to headphone mode - currentMode = .headphone - priorityManager.currentMode = .headphone - } else if !hasConnectedHeadphones && hasConnectedSpeakers && currentMode == .headphone { - // All headphones disconnected - switch back to speaker mode - currentMode = .speaker - priorityManager.currentMode = .speaker - } - } -} diff --git a/AudioPriorityBar/Models/AudioDevice.swift b/AudioPriorityBar/Models/AudioDevice.swift index 64f68ec..ac0d3b5 100644 --- a/AudioPriorityBar/Models/AudioDevice.swift +++ b/AudioPriorityBar/Models/AudioDevice.swift @@ -6,25 +6,6 @@ enum AudioDeviceType: String, Codable { case output } -enum OutputCategory: String, Codable, CaseIterable { - case speaker - case headphone - - var icon: String { - switch self { - case .speaker: return "speaker.wave.2.fill" - case .headphone: return "headphones" - } - } - - var label: String { - switch self { - case .speaker: return "Speakers" - case .headphone: return "Headphones" - } - } -} - struct AudioDevice: Identifiable, Equatable, Hashable { let id: AudioObjectID let uid: String @@ -36,6 +17,10 @@ struct AudioDevice: Identifiable, Equatable, Hashable { id != kAudioObjectUnknown } + var roleIdentifier: String { + "\(type.rawValue):\(uid)" + } + // Create a disconnected placeholder from stored device static func disconnected(uid: String, name: String, type: AudioDeviceType) -> AudioDevice { AudioDevice(id: 0, uid: uid, name: name, type: type, isConnected: false) diff --git a/AudioPriorityBar/Models/DefaultDeviceChangeTracker.swift b/AudioPriorityBar/Models/DefaultDeviceChangeTracker.swift new file mode 100644 index 0000000..02c9456 --- /dev/null +++ b/AudioPriorityBar/Models/DefaultDeviceChangeTracker.swift @@ -0,0 +1,49 @@ +import CoreAudio + +struct DefaultDeviceChangeTracker { + private var preservedInputId: AudioObjectID? + private var preservedOutputId: AudioObjectID? + + mutating func preserveManualSelection(_ deviceId: AudioObjectID, type: AudioDeviceType) { + switch type { + case .input: + preservedInputId = deviceId + case .output: + preservedOutputId = deviceId + } + } + + mutating func shouldPreserveManualSelection( + currentDeviceId: AudioObjectID?, + type: AudioDeviceType + ) -> Bool { + let preserved: AudioObjectID? + switch type { + case .input: + preserved = preservedInputId + case .output: + preserved = preservedOutputId + } + + guard let preserved else { return false } + guard preserved == currentDeviceId else { + clearManualSelection(type: type) + return false + } + return true + } + + mutating func clearManualSelection(type: AudioDeviceType) { + switch type { + case .input: + preservedInputId = nil + case .output: + preservedOutputId = nil + } + } + + mutating func clearAllManualSelections() { + preservedInputId = nil + preservedOutputId = nil + } +} diff --git a/AudioPriorityBar/Models/Headphones.swift b/AudioPriorityBar/Models/Headphones.swift deleted file mode 100644 index 0b2e9b4..0000000 --- a/AudioPriorityBar/Models/Headphones.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -/// Keywords used to detect headphone-like devices and auto-categorize them -struct HeadphoneDetection { - /// Device name keywords that indicate headphones/earbuds - static let keywords: [String] = [ - // Generic terms - "headphone", - "headset", - "earphone", - "earbud", - "earbuds", - "buds", - "ear", - "pods", - - // Apple - "airpods", - "earpods", - "beats", - "powerbeats", - "beatsx", - "beats fit", - "beats solo", - "beats studio", - - // Sony - "wh-1000", // WH-1000XM series - "wf-1000", // WF-1000XM series - "linkbuds", - "inzone", - - // Samsung - "galaxy buds", - "buds pro", - "buds live", - "buds fe", - - // Bose - "quietcomfort", - "qc ultra", - "qc45", - "qc35", - "soundsport", - "sport earbuds", - - // Sennheiser - "momentum", - "hd 4", - "hd 5", - "pxc", - - // Jabra - "jabra", - "elite", - "evolve", - - // JBL - "jbl tune", - "jbl live", - "jbl tour", - "jbl reflect", - - // Other brands - "anker", - "soundcore", - "skullcandy", - "nothing ear", - "oneplus buds", - "pixel buds", - "huawei freebuds", - "oppo enco", - "technics eah", - "bowers", - "b&w px", - "denon perl", - "focal bathys", - "hifiman", - "shure aonic", - "audio-technica ath", - "beyerdynamic", - "marshall", - "bang & olufsen", - "b&o", - "akg", - "plantronics", - "poly", - "razer", - "steelseries", - "hyperx", - "logitech g pro", - "astro", - "corsair", - "1more", - "tozo", - "edifier", - "fiio", - "moondrop", - ] - - /// Check if a device name matches headphone patterns - static func isHeadphone(deviceName: String) -> Bool { - let nameLower = deviceName.lowercased() - return keywords.contains { nameLower.contains($0) } - } -} diff --git a/AudioPriorityBar/Services/AudioDeviceService.swift b/AudioPriorityBar/Services/AudioDeviceService.swift index 43431fe..34d5203 100644 --- a/AudioPriorityBar/Services/AudioDeviceService.swift +++ b/AudioPriorityBar/Services/AudioDeviceService.swift @@ -4,9 +4,12 @@ import AudioToolbox class AudioDeviceService { var onDevicesChanged: (() -> Void)? + var onDefaultDeviceChanged: ((AudioDeviceType) -> Void)? var onMuteOrVolumeChanged: (() -> Void)? - private var listenerBlock: AudioObjectPropertyListenerBlock? + private var deviceListListenerBlock: AudioObjectPropertyListenerBlock? + private var inputDefaultListenerBlock: AudioObjectPropertyListenerBlock? + private var outputDefaultListenerBlock: AudioObjectPropertyListenerBlock? private var muteVolumeListenerBlock: AudioObjectPropertyListenerBlock? private var monitoredDeviceIds: Set = [] @@ -82,7 +85,8 @@ class AudioDeviceService { return status == noErr ? deviceId : nil } - func setDefaultDevice(_ deviceId: AudioObjectID, type: AudioDeviceType) { + @discardableResult + func setDefaultDevice(_ deviceId: AudioObjectID, type: AudioDeviceType) -> Bool { let selector: AudioObjectPropertySelector = type == .input ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice @@ -96,7 +100,7 @@ class AudioDeviceService { var mutableDeviceId = deviceId let dataSize = UInt32(MemoryLayout.size) - AudioObjectSetPropertyData( + let status = AudioObjectSetPropertyData( AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, @@ -104,6 +108,7 @@ class AudioDeviceService { dataSize, &mutableDeviceId ) + return status == noErr } func getOutputVolume() -> Float { @@ -235,20 +240,24 @@ class AudioDeviceService { mElement: kAudioObjectPropertyElementMain ) - listenerBlock = { [weak self] _, _ in + let deviceListBlock: AudioObjectPropertyListenerBlock = { [weak self] _, _ in self?.onDevicesChanged?() - // Re-register mute/volume listeners when devices change self?.updateMuteVolumeListeners() } + deviceListListenerBlock = deviceListBlock AudioObjectAddPropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), &propertyAddress, DispatchQueue.main, - listenerBlock! + deviceListBlock ) - // Also listen to default device changes + let inputDefaultBlock: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.onDefaultDeviceChanged?(.input) + } + inputDefaultListenerBlock = inputDefaultBlock + var inputDefaultAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, @@ -258,9 +267,14 @@ class AudioDeviceService { AudioObjectID(kAudioObjectSystemObject), &inputDefaultAddress, DispatchQueue.main, - listenerBlock! + inputDefaultBlock ) + let outputDefaultBlock: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.onDefaultDeviceChanged?(.output) + } + outputDefaultListenerBlock = outputDefaultBlock + var outputDefaultAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, @@ -270,7 +284,7 @@ class AudioDeviceService { AudioObjectID(kAudioObjectSystemObject), &outputDefaultAddress, DispatchQueue.main, - listenerBlock! + outputDefaultBlock ) // Initial setup of mute/volume listeners @@ -389,20 +403,20 @@ 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 - ) + if let deviceListListenerBlock { + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + DispatchQueue.main, + deviceListListenerBlock + ) + } // Also remove default device change listeners var inputDefaultAddress = AudioObjectPropertyAddress( @@ -410,26 +424,32 @@ class AudioDeviceService { mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) - AudioObjectRemovePropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &inputDefaultAddress, - DispatchQueue.main, - block - ) + if let inputDefaultListenerBlock { + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &inputDefaultAddress, + DispatchQueue.main, + inputDefaultListenerBlock + ) + } var outputDefaultAddress = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) - AudioObjectRemovePropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &outputDefaultAddress, - DispatchQueue.main, - block - ) + if let outputDefaultListenerBlock { + AudioObjectRemovePropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &outputDefaultAddress, + DispatchQueue.main, + outputDefaultListenerBlock + ) + } - listenerBlock = nil + deviceListListenerBlock = nil + inputDefaultListenerBlock = nil + outputDefaultListenerBlock = nil } private func createDevice(id: AudioObjectID, type: AudioDeviceType) -> AudioDevice? { diff --git a/AudioPriorityBar/Services/AudioManager.swift b/AudioPriorityBar/Services/AudioManager.swift new file mode 100644 index 0000000..5174729 --- /dev/null +++ b/AudioPriorityBar/Services/AudioManager.swift @@ -0,0 +1,453 @@ +import Combine +import Foundation +import CoreAudio + +@MainActor +class AudioManager: ObservableObject { + @Published var inputDevices: [AudioDevice] = [] + @Published var outputDevices: [AudioDevice] = [] + @Published var currentInputId: AudioObjectID? + @Published var currentOutputId: AudioObjectID? + @Published var volume: Float = 0 + @Published var isEditMode: Bool = false + @Published var isOutputAutoSwitchEnabled: Bool = true + @Published var isInputAutoSwitchEnabled: Bool = true + @Published var isAutomationPaused: Bool = false + @Published var mutedDeviceIds: Set = [] + @Published var isActiveOutputMuted: Bool = false + @Published var isActiveInputMuted: Bool = false + @Published var micFlashState: Bool = false + + private let deviceService = AudioDeviceService() + private var micFlashTimer: Timer? + let priorityManager = PriorityManager() + private var connectedDeviceRoleIdentifiers: Set = [] + private var connectedInputDevices: [AudioDevice] = [] + private var connectedOutputDevices: [AudioDevice] = [] + private var defaultDeviceChangeTracker = DefaultDeviceChangeTracker() + + var menuBarIcon: String { + isAutomationPaused ? "pause.circle.fill" : "speaker.wave.2.fill" + } + + var isOutputAutomationActive: Bool { + isOutputAutoSwitchEnabled && !isAutomationPaused + } + + var isInputAutomationActive: Bool { + isInputAutoSwitchEnabled && !isAutomationPaused + } + + func isAutomationActive(for type: AudioDeviceType) -> Bool { + switch type { + case .input: + return isInputAutomationActive + case .output: + return isOutputAutomationActive + } + } + + func refreshVolume() { + volume = deviceService.getOutputVolume() + } + + func refreshMuteStatus() { + var muted: Set = [] + for device in connectedInputDevices { + if deviceService.isDeviceMuted(device.id, type: .input) { + muted.insert(device.id) + } + } + for device in connectedOutputDevices { + if deviceService.isDeviceMuted(device.id, type: .output) { + muted.insert(device.id) + } + } + mutedDeviceIds = muted + if let outputId = currentOutputId { + isActiveOutputMuted = muted.contains(outputId) + } else { + isActiveOutputMuted = false + } + if let inputId = currentInputId { + isActiveInputMuted = muted.contains(inputId) + } else { + isActiveInputMuted = false + } + if isActiveInputMuted && micFlashTimer == nil { + micFlashTimer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.micFlashState.toggle() + } + } + } else if !isActiveInputMuted && micFlashTimer != nil { + micFlashTimer?.invalidate() + micFlashTimer = nil + micFlashState = false + } + } + + func isDeviceMuted(_ device: AudioDevice) -> Bool { + mutedDeviceIds.contains(device.id) + } + + func setVolume(_ newVolume: Float) { + volume = newVolume + deviceService.setOutputVolume(newVolume) + } + + init() { + isOutputAutoSwitchEnabled = priorityManager.isOutputAutoSwitchEnabled + isInputAutoSwitchEnabled = priorityManager.isInputAutoSwitchEnabled + isAutomationPaused = priorityManager.isGlobalAutoSwitchPaused + refreshDevices() + refreshVolume() + refreshMuteStatus() + setupDeviceChangeListener() + setupMuteVolumeListener() + if isInputAutomationActive { + applyHighestPriorityInput() + } + if isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + private func setupMuteVolumeListener() { + deviceService.onMuteOrVolumeChanged = { [weak self] in + Task { @MainActor in + self?.handleMuteOrVolumeChange() + } + } + } + + private func handleMuteOrVolumeChange() { + refreshMuteStatus() + refreshVolume() + } + + func refreshDevices() { + let allConnectedDevices = deviceService.getDevices() + connectedDeviceRoleIdentifiers = Set(allConnectedDevices.map { $0.roleIdentifier }) + for device in allConnectedDevices { + priorityManager.rememberDevice(device.uid, name: device.name, isInput: device.type == .input) + } + let connectedInputs = allConnectedDevices.filter { $0.type == .input } + let connectedOutputs = allConnectedDevices.filter { $0.type == .output } + connectedInputDevices = connectedInputs + connectedOutputDevices = connectedOutputs + priorityManager.registerDiscoveredUIDs(connectedInputs.map(\.uid), type: .input) + priorityManager.registerDiscoveredUIDs(connectedOutputs.map(\.uid), type: .output) + + if isEditMode { + let knownDevices = priorityManager.getKnownDevices() + var allInputs: [AudioDevice] = connectedInputs + for stored in knownDevices where stored.isInput { + let roleIdentifier = "\(AudioDeviceType.input.rawValue):\(stored.uid)" + if !connectedDeviceRoleIdentifiers.contains(roleIdentifier) { + allInputs.append(.disconnected(uid: stored.uid, name: stored.name, type: .input)) + } + } + var allOutputs: [AudioDevice] = connectedOutputs + for stored in knownDevices where !stored.isInput { + let roleIdentifier = "\(AudioDeviceType.output.rawValue):\(stored.uid)" + if !connectedDeviceRoleIdentifiers.contains(roleIdentifier) { + allOutputs.append(.disconnected(uid: stored.uid, name: stored.name, type: .output)) + } + } + appendCanonicalPlaceholders(to: &allInputs, type: .input) + appendCanonicalPlaceholders(to: &allOutputs, type: .output) + inputDevices = priorityManager.sortByPriority(allInputs, type: .input) + outputDevices = priorityManager.sortByPriority(allOutputs, type: .output) + } else { + // Auto-use eligibility is separate from visibility; only ignored devices are hidden here. + let visibleInputs = connectedInputs.filter { !priorityManager.isHidden($0) } + inputDevices = priorityManager.sortByPriority(visibleInputs, type: .input) + + let visibleOutputs = connectedOutputs.filter { !priorityManager.isHidden($0) } + outputDevices = priorityManager.sortByPriority(visibleOutputs, type: .output) + } + currentInputId = deviceService.getCurrentDefaultDevice(type: .input) + currentOutputId = deviceService.getCurrentDefaultDevice(type: .output) + } + + func toggleEditMode() { + isEditMode.toggle() + refreshDevices() + } + + func isDeviceConnected(_ device: AudioDevice) -> Bool { + connectedDeviceRoleIdentifiers.contains(device.roleIdentifier) + } + + func setOutputAutoSwitchEnabled(_ enabled: Bool) { + isOutputAutoSwitchEnabled = enabled + priorityManager.isOutputAutoSwitchEnabled = enabled + if isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func setInputAutoSwitchEnabled(_ enabled: Bool) { + isInputAutoSwitchEnabled = enabled + priorityManager.isInputAutoSwitchEnabled = enabled + if isInputAutomationActive { + applyHighestPriorityInput() + } + } + + func setAutomationPaused(_ paused: Bool) { + isAutomationPaused = paused + priorityManager.isGlobalAutoSwitchPaused = paused + + guard !paused else { return } + if isOutputAutoSwitchEnabled { + applyHighestPriorityOutput() + } + if isInputAutoSwitchEnabled { + applyHighestPriorityInput() + } + } + + func hideDevice(_ device: AudioDevice) { + priorityManager.hideDevice(device) + refreshDevices() + if device.type == .input, isInputAutomationActive { + applyHighestPriorityInput() + } else if device.type == .output, isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func unhideDevice(_ device: AudioDevice) { + priorityManager.unhideDevice(device) + refreshDevices() + if device.type == .input, isInputAutomationActive { + applyHighestPriorityInput() + } else if device.type == .output, isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func isDeviceIgnored(_ device: AudioDevice) -> Bool { + priorityManager.isHidden(device) + } + + func isAutoUseDisabled(_ device: AudioDevice) -> Bool { + priorityManager.isAutoUseDisabled(device) + } + + func setAutoUseDisabled(_ device: AudioDevice, disabled: Bool) { + priorityManager.setAutoUseDisabled(device, disabled: disabled) + refreshDevices() + if !device.isConnected { + return + } + if device.type == .input, isInputAutomationActive { + applyHighestPriorityInput() + } else if device.type == .output, isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func moveInputDevice(from source: IndexSet, to destination: Int) { + inputDevices.move(fromOffsets: source, toOffset: destination) + if isEditMode { + guard priorityManager.reorderCanonical(inputDevices, type: .input) else { + refreshDevices() + return + } + } else { + priorityManager.mergeVisibleOrder(inputDevices, type: .input) + } + if isInputAutomationActive { + applyHighestPriorityInput() + } + } + + func moveOutputDevice(from source: IndexSet, to destination: Int) { + outputDevices.move(fromOffsets: source, toOffset: destination) + if isEditMode { + guard priorityManager.reorderCanonical(outputDevices, type: .output) else { + refreshDevices() + return + } + } else { + priorityManager.mergeVisibleOrder(outputDevices, type: .output) + } + if isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func promoteInputDevice(_ device: AudioDevice) { + priorityManager.promote(device, visibleDevices: inputDevices) + refreshDevices() + applyInputDevice( + device, + preserveManualSelection: !priorityManager.isEligibleForAutoUse(device) + ) + } + + func promoteOutputDevice(_ device: AudioDevice) { + priorityManager.promote(device, visibleDevices: outputDevices) + refreshDevices() + applyOutputDevice( + device, + preserveManualSelection: !priorityManager.isEligibleForAutoUse(device) + ) + } + + func forgetDevice(_ device: AudioDevice) { + priorityManager.forgetDevice(device.uid, type: device.type) + refreshDevices() + if device.type == .input, isInputAutomationActive { + applyHighestPriorityInput() + } else if device.type == .output, isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + func setInputDevice(_ device: AudioDevice) { + applyInputDevice(device) + } + + func setOutputDevice(_ device: AudioDevice) { + applyOutputDevice(device) + } + + private func applyInputDevice( + _ device: AudioDevice, + preserveManualSelection: Bool = false + ) { + guard device.isConnected else { + defaultDeviceChangeTracker.clearManualSelection(type: .input) + return + } + if preserveManualSelection { + defaultDeviceChangeTracker.preserveManualSelection(device.id, type: .input) + } else { + defaultDeviceChangeTracker.clearManualSelection(type: .input) + } + guard currentInputId != device.id else { return } + if deviceService.setDefaultDevice(device.id, type: .input) { + currentInputId = device.id + } else { + defaultDeviceChangeTracker.clearManualSelection(type: .input) + currentInputId = deviceService.getCurrentDefaultDevice(type: .input) + } + } + + private func applyOutputDevice( + _ device: AudioDevice, + preserveManualSelection: Bool = false + ) { + guard device.isConnected else { + defaultDeviceChangeTracker.clearManualSelection(type: .output) + return + } + if preserveManualSelection { + defaultDeviceChangeTracker.preserveManualSelection(device.id, type: .output) + } else { + defaultDeviceChangeTracker.clearManualSelection(type: .output) + } + guard currentOutputId != device.id else { return } + if deviceService.setDefaultDevice(device.id, type: .output) { + currentOutputId = device.id + } else { + defaultDeviceChangeTracker.clearManualSelection(type: .output) + currentOutputId = deviceService.getCurrentDefaultDevice(type: .output) + } + } + + private func applyHighestPriorityInput() { + guard isInputAutomationActive, + priorityManager.hasPriorities(type: .input) else { return } + + let sortedConnectedInputs = priorityManager.sortByPriority(connectedInputDevices, type: .input) + if let first = sortedConnectedInputs.first(where: priorityManager.isEligibleForAutoUse) { + applyInputDevice(first) + } + } + + private func applyHighestPriorityOutput() { + guard isOutputAutomationActive, + priorityManager.hasPriorities(type: .output) else { return } + + let sortedConnectedOutputs = priorityManager.sortByPriority(connectedOutputDevices, type: .output) + if let first = sortedConnectedOutputs.first(where: priorityManager.isEligibleForAutoUse) { + applyOutputDevice(first) + } + refreshMuteStatus() + } + + private func setupDeviceChangeListener() { + deviceService.onDevicesChanged = { [weak self] in + Task { @MainActor in + self?.handleDeviceChange() + } + } + deviceService.onDefaultDeviceChanged = { [weak self] type in + Task { @MainActor in + self?.handleDefaultDeviceChange(type: type) + } + } + deviceService.startListening() + } + + private func handleDefaultDeviceChange(type: AudioDeviceType) { + let currentDeviceId = deviceService.getCurrentDefaultDevice(type: type) + let preservesManualSelection = defaultDeviceChangeTracker.shouldPreserveManualSelection( + currentDeviceId: currentDeviceId, + type: type + ) + + switch type { + case .input: + currentInputId = currentDeviceId + case .output: + currentOutputId = currentDeviceId + refreshVolume() + } + refreshMuteStatus() + + guard !preservesManualSelection else { return } + if type == .input, isInputAutomationActive { + applyHighestPriorityInput() + } else if type == .output, isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + private func handleDeviceChange() { + defaultDeviceChangeTracker.clearAllManualSelections() + refreshDevices() + refreshMuteStatus() + + if isInputAutomationActive { + applyHighestPriorityInput() + } + + if isOutputAutomationActive { + applyHighestPriorityOutput() + } + } + + private func appendCanonicalPlaceholders( + to devices: inout [AudioDevice], + type: AudioDeviceType + ) { + var existingUIDs = Set(devices.map(\.uid)) + for uid in priorityManager.priorityUIDs(type: type) where !existingUIDs.contains(uid) { + let suffix = uid.count > 8 ? String(uid.suffix(8)) : uid + devices.append( + .disconnected( + uid: uid, + name: "Unknown Device (\(suffix))", + type: type + ) + ) + existingUIDs.insert(uid) + } + } +} diff --git a/AudioPriorityBar/Services/PriorityManager.swift b/AudioPriorityBar/Services/PriorityManager.swift index 337dc2f..b5d9b2a 100644 --- a/AudioPriorityBar/Services/PriorityManager.swift +++ b/AudioPriorityBar/Services/PriorityManager.swift @@ -32,16 +32,41 @@ struct StoredDevice: Codable, Equatable { } class PriorityManager { - private let defaults = UserDefaults.standard + private let defaults: UserDefaults private let inputPrioritiesKey = "inputPriorities" + private let outputPrioritiesKey = "outputPriorities" + private let inputPrioritiesConfiguredKey = "inputPrioritiesConfigured" + private let outputPrioritiesConfiguredKey = "outputPrioritiesConfigured" private let speakerPrioritiesKey = "speakerPriorities" private let headphonePrioritiesKey = "headphonePriorities" - private let deviceCategoriesKey = "deviceCategories" private let currentModeKey = "currentMode" private let customModeKey = "customMode" - private let hiddenDevicesKey = "hiddenDevices" + private let outputAutoSwitchEnabledKey = "outputAutoSwitchEnabled" + private let inputAutoSwitchEnabledKey = "inputAutoSwitchEnabled" + private let globalAutoSwitchPausedKey = "globalAutoSwitchPaused" + private let automationSettingsMigrationKey = "automationSettingsMigration_v1" private let knownDevicesKey = "knownDevices" + private let legacyNeverUseKey = "neverUseDevices" + private let inputAutoUseDisabledKey = "inputAutoUseDisabledDevices" + private let outputAutoUseDisabledKey = "outputAutoUseDisabledDevices" + private let roleSpecificAutoUseMigrationKey = "roleSpecificAutoUseMigration_v1" + private let hiddenOutputsKey = "hiddenOutputs" + private let hiddenMicsKey = "hiddenMics" + private let hiddenSpeakersKey = "hiddenSpeakers" + private let hiddenHeadphonesKey = "hiddenHeadphones" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + let hadUnifiedOutputPriorities = defaults.object(forKey: outputPrioritiesKey) != nil + + migrateLegacyNeverUseDevicesIfNeeded() + migrateAutomationSettingsIfNeeded(hadUnifiedOutputPriorities: hadUnifiedOutputPriorities) + migrateUnifiedOutputPrioritiesIfNeeded() + migrateUnifiedHiddenOutputsIfNeeded() + initializePriorityConfigurationIfNeeded(type: .input) + initializePriorityConfigurationIfNeeded(type: .output) + } // MARK: - Known Devices (Persistent Memory) @@ -56,7 +81,7 @@ class PriorityManager { func rememberDevice(_ uid: String, name: String, isInput: Bool) { var known = getKnownDevices() let now = Date() - if let index = known.firstIndex(where: { $0.uid == uid }) { + if let index = known.firstIndex(where: { $0.uid == uid && $0.isInput == isInput }) { // Update name and lastSeen known[index] = StoredDevice(uid: uid, name: name, isInput: isInput, lastSeen: now) } else { @@ -65,13 +90,22 @@ class PriorityManager { saveKnownDevices(known) } - func getStoredDevice(uid: String) -> StoredDevice? { - getKnownDevices().first { $0.uid == uid } + func getStoredDevice(for device: AudioDevice) -> StoredDevice? { + let isInput = device.type == .input + return getKnownDevices().first { $0.uid == device.uid && $0.isInput == isInput } } - func forgetDevice(_ uid: String) { + func forgetDevice(_ uid: String, type: AudioDeviceType? = nil) { var known = getKnownDevices() - known.removeAll { $0.uid == uid } + if let type { + let isInput = type == .input + known.removeAll { $0.uid == uid && $0.isInput == isInput } + removeRoleState(uid: uid, type: type) + } else { + known.removeAll { $0.uid == uid } + removeRoleState(uid: uid, type: .input) + removeRoleState(uid: uid, type: .output) + } saveKnownDevices(known) } @@ -81,97 +115,120 @@ class PriorityManager { } } - // MARK: - Mode Management + // MARK: - Automation Settings - var currentMode: OutputCategory { - get { - guard let raw = defaults.string(forKey: currentModeKey), - let mode = OutputCategory(rawValue: raw) else { - return .speaker - } - return mode - } - set { - defaults.set(newValue.rawValue, forKey: currentModeKey) - } - } - - var isCustomMode: Bool { - get { defaults.bool(forKey: customModeKey) } - set { defaults.set(newValue, forKey: customModeKey) } + var isOutputAutoSwitchEnabled: Bool { + get { storedBool(forKey: outputAutoSwitchEnabledKey, defaultValue: true) } + set { defaults.set(newValue, forKey: outputAutoSwitchEnabledKey) } } - // MARK: - Device Categories - - func getCategory(for device: AudioDevice) -> OutputCategory { - let categories = defaults.dictionary(forKey: deviceCategoriesKey) as? [String: String] ?? [:] - if let raw = categories[device.uid], let category = OutputCategory(rawValue: raw) { - return category - } - // Default headphone-like devices to headphone category - if HeadphoneDetection.isHeadphone(deviceName: device.name) { - return .headphone - } - return .speaker + var isInputAutoSwitchEnabled: Bool { + get { storedBool(forKey: inputAutoSwitchEnabledKey, defaultValue: true) } + set { defaults.set(newValue, forKey: inputAutoSwitchEnabledKey) } } - func setCategory(_ category: OutputCategory, for device: AudioDevice) { - var categories = defaults.dictionary(forKey: deviceCategoriesKey) as? [String: String] ?? [:] - categories[device.uid] = category.rawValue - defaults.set(categories, forKey: deviceCategoriesKey) + var isGlobalAutoSwitchPaused: Bool { + get { storedBool(forKey: globalAutoSwitchPausedKey, defaultValue: false) } + set { defaults.set(newValue, forKey: globalAutoSwitchPausedKey) } } - // MARK: - Never Use Devices (never auto-selected) + // MARK: - Auto-Use Eligibility - private let neverUseKey = "neverUseDevices" - - func isNeverUse(_ device: AudioDevice) -> Bool { - let list = defaults.array(forKey: neverUseKey) as? [String] ?? [] + func isAutoUseDisabled(_ device: AudioDevice) -> Bool { + let list = storedStringList(forKey: autoUseDisabledKey(for: device.type)) return list.contains(device.uid) } - func setNeverUse(_ device: AudioDevice, neverUse: Bool) { - var list = defaults.array(forKey: neverUseKey) as? [String] ?? [] - if neverUse { + func isEligibleForAutoUse(_ device: AudioDevice) -> Bool { + device.isConnected && !isHidden(device) && !isAutoUseDisabled(device) + } + + func setAutoUseDisabled(_ device: AudioDevice, disabled: Bool) { + let key = autoUseDisabledKey(for: device.type) + var list = storedStringList(forKey: key) + if disabled { if !list.contains(device.uid) { list.append(device.uid) } } else { list.removeAll { $0 == device.uid } } - defaults.set(list, forKey: neverUseKey) + defaults.set(list, forKey: key) } - // MARK: - Hidden Devices (per category) + private func autoUseDisabledKey(for type: AudioDeviceType) -> String { + switch type { + case .input: + return inputAutoUseDisabledKey + case .output: + return outputAutoUseDisabledKey + } + } - private let hiddenMicsKey = "hiddenMics" - private let hiddenSpeakersKey = "hiddenSpeakers" - private let hiddenHeadphonesKey = "hiddenHeadphones" + private func migrateLegacyNeverUseDevicesIfNeeded() { + guard !defaults.bool(forKey: roleSpecificAutoUseMigrationKey) else { return } - func isHidden(_ device: AudioDevice) -> Bool { - let key = hiddenKey(for: device) - let hidden = defaults.array(forKey: key) as? [String] ?? [] - return hidden.contains(device.uid) + let legacyList = storedStringList(forKey: legacyNeverUseKey) + if !legacyList.isEmpty { + appendUnique(legacyList, to: inputAutoUseDisabledKey) + appendUnique(legacyList, to: outputAutoUseDisabledKey) + } + + defaults.set(true, forKey: roleSpecificAutoUseMigrationKey) } - func isHidden(_ device: AudioDevice, inCategory category: OutputCategory) -> Bool { - let key = category == .speaker ? hiddenSpeakersKey : hiddenHeadphonesKey - let hidden = defaults.array(forKey: key) as? [String] ?? [] - return hidden.contains(device.uid) + private func appendUnique(_ uids: [String], to key: String) { + var list = storedStringList(forKey: key) + for uid in uids where !list.contains(uid) { + list.append(uid) + } + defaults.set(list, forKey: key) } - func hideDevice(_ device: AudioDevice) { - let key = hiddenKey(for: device) - var hidden = defaults.array(forKey: key) as? [String] ?? [] - if !hidden.contains(device.uid) { - hidden.append(device.uid) - defaults.set(hidden, forKey: key) + private func migrateAutomationSettingsIfNeeded(hadUnifiedOutputPriorities: Bool) { + guard !defaults.bool(forKey: automationSettingsMigrationKey) else { return } + + var outputAutoSwitchEnabled = true + let inputAutoSwitchEnabled = true + var globalAutoSwitchPaused = false + + if defaults.bool(forKey: customModeKey) { + if hadUnifiedOutputPriorities { + // The local unified-output build used Custom Mode as an Output-only override. + outputAutoSwitchEnabled = false + } else { + // Upstream used Custom Mode to pause both roles. + globalAutoSwitchPaused = true + } } + + setBoolIfMissing(outputAutoSwitchEnabled, forKey: outputAutoSwitchEnabledKey) + setBoolIfMissing(inputAutoSwitchEnabled, forKey: inputAutoSwitchEnabledKey) + setBoolIfMissing(globalAutoSwitchPaused, forKey: globalAutoSwitchPausedKey) + defaults.set(true, forKey: automationSettingsMigrationKey) + } + + private func storedBool(forKey key: String, defaultValue: Bool) -> Bool { + guard defaults.object(forKey: key) != nil else { return defaultValue } + return defaults.bool(forKey: key) + } + + private func setBoolIfMissing(_ value: Bool, forKey key: String) { + guard defaults.object(forKey: key) == nil else { return } + defaults.set(value, forKey: key) + } + + // MARK: - Hidden Devices (per role) + + func isHidden(_ device: AudioDevice) -> Bool { + let key = hiddenKey(for: device) + let hidden = storedStringList(forKey: key) + return hidden.contains(device.uid) } - func hideDevice(_ device: AudioDevice, inCategory category: OutputCategory) { - let key = category == .speaker ? hiddenSpeakersKey : hiddenHeadphonesKey - var hidden = defaults.array(forKey: key) as? [String] ?? [] + func hideDevice(_ device: AudioDevice) { + let key = hiddenKey(for: device) + var hidden = storedStringList(forKey: key) if !hidden.contains(device.uid) { hidden.append(device.uid) defaults.set(hidden, forKey: key) @@ -180,77 +237,228 @@ class PriorityManager { func unhideDevice(_ device: AudioDevice) { let key = hiddenKey(for: device) - var hidden = defaults.array(forKey: key) as? [String] ?? [] - hidden.removeAll { $0 == device.uid } - defaults.set(hidden, forKey: key) - } - - func unhideDevice(_ device: AudioDevice, fromCategory category: OutputCategory) { - let key = category == .speaker ? hiddenSpeakersKey : hiddenHeadphonesKey - var hidden = defaults.array(forKey: key) as? [String] ?? [] + var hidden = storedStringList(forKey: key) hidden.removeAll { $0 == device.uid } defaults.set(hidden, forKey: key) } private func hiddenKey(for device: AudioDevice) -> String { - if device.type == .input { - return hiddenMicsKey - } else { - let category = getCategory(for: device) - return category == .speaker ? hiddenSpeakersKey : hiddenHeadphonesKey - } + device.type == .input ? hiddenMicsKey : hiddenOutputsKey } // MARK: - Priority Management func sortByPriority(_ devices: [AudioDevice], type: AudioDeviceType) -> [AudioDevice] { - let key = priorityKey(for: type, category: nil) - return sortDevices(devices, usingKey: key) + sortDevices(devices, type: type) } - func sortByPriority(_ devices: [AudioDevice], category: OutputCategory) -> [AudioDevice] { - let key = priorityKey(for: .output, category: category) - return sortDevices(devices, usingKey: key) + func priorityUIDs(type: AudioDeviceType) -> [String] { + storedStringList(forKey: priorityKey(for: type)) } - func savePriorities(_ devices: [AudioDevice], type: AudioDeviceType) { - let key = priorityKey(for: type, category: nil) - savePriorities(devices, key: key) + func isPriorityConfigured(type: AudioDeviceType) -> Bool { + let key = priorityConfiguredKey(for: type) + if defaults.object(forKey: key) != nil { + return defaults.bool(forKey: key) + } + return !priorityUIDs(type: type).isEmpty + } + + func registerDiscoveredUIDs(_ uids: [String], type: AudioDeviceType) { + guard isPriorityConfigured(type: type) else { return } + + var canonical = priorityUIDs(type: type) + let existing = Set(canonical) + let newUIDs = unique(uids) + .filter { !existing.contains($0) } + .sorted() + guard !newUIDs.isEmpty else { return } + + canonical.append(contentsOf: newUIDs) + saveCanonical(canonical, type: type) } - func savePriorities(_ devices: [AudioDevice], category: OutputCategory) { - let key = priorityKey(for: .output, category: category) - savePriorities(devices, key: key) + func mergeVisibleOrder(_ devices: [AudioDevice], type: AudioDeviceType) { + let orderedVisible = deviceUIDs(devices, type: type) + guard !orderedVisible.isEmpty else { return } + + var canonical = priorityUIDs(type: type) + let known = knownUIDs(type: type) + + if !isPriorityConfigured(type: type) { + saveCanonical(unique(orderedVisible + known), type: type) + setPriorityConfigured(true, type: type) + return + } + + canonical = unique(canonical + known + orderedVisible) + let visibleSet = Set(orderedVisible) + var visibleIterator = orderedVisible.makeIterator() + let merged = canonical.map { uid in + guard visibleSet.contains(uid) else { return uid } + return visibleIterator.next() ?? uid + } + + saveCanonical(merged, type: type) + } + + func promote(_ device: AudioDevice, visibleDevices: [AudioDevice]) { + let type = device.type + let visible = deviceUIDs(visibleDevices, type: type) + var canonical = priorityUIDs(type: type) + + if !isPriorityConfigured(type: type) { + canonical = unique(visible + knownUIDs(type: type)) + } else { + canonical = unique(canonical + visible + knownUIDs(type: type)) + } + + canonical.removeAll { $0 == device.uid } + canonical.insert(device.uid, at: 0) + saveCanonical(canonical, type: type) + setPriorityConfigured(true, type: type) + } + + @discardableResult + func reorderCanonical(_ devices: [AudioDevice], type: AudioDeviceType) -> Bool { + let ordered = deviceUIDs(devices, type: type) + let expectedUniverse = Set(unique(priorityUIDs(type: type) + knownUIDs(type: type))) + guard ordered.count == expectedUniverse.count, + Set(ordered) == expectedUniverse else { + return false + } + + saveCanonical(ordered, type: type) + setPriorityConfigured(true, type: type) + return true + } + + func hasPriorities(type: AudioDeviceType) -> Bool { + !priorityUIDs(type: type).isEmpty } // MARK: - Private Helpers - private func priorityKey(for type: AudioDeviceType, category: OutputCategory?) -> String { + private func priorityKey(for type: AudioDeviceType) -> String { switch type { case .input: return inputPrioritiesKey case .output: - switch category { - case .speaker, .none: - return speakerPrioritiesKey - case .headphone: - return headphonePrioritiesKey - } + return outputPrioritiesKey } } - private func sortDevices(_ devices: [AudioDevice], usingKey key: String) -> [AudioDevice] { - let priorities = defaults.array(forKey: key) as? [String] ?? [] + private func priorityConfiguredKey(for type: AudioDeviceType) -> String { + switch type { + case .input: + return inputPrioritiesConfiguredKey + case .output: + return outputPrioritiesConfiguredKey + } + } + + private func migrateUnifiedOutputPrioritiesIfNeeded() { + guard defaults.object(forKey: outputPrioritiesKey) == nil else { return } + + let headphonePriorities = storedStringList(forKey: headphonePrioritiesKey) + let speakerPriorities = storedStringList(forKey: speakerPrioritiesKey) + let legacyMode = defaults.string(forKey: currentModeKey) + let mergedPriorities = legacyMode == "headphone" + ? headphonePriorities + speakerPriorities + : speakerPriorities + headphonePriorities + defaults.set(unique(mergedPriorities), forKey: outputPrioritiesKey) + } + + private func migrateUnifiedHiddenOutputsIfNeeded() { + guard defaults.object(forKey: hiddenOutputsKey) == nil else { return } + + let hiddenHeadphones = storedStringList(forKey: hiddenHeadphonesKey) + let hiddenSpeakers = storedStringList(forKey: hiddenSpeakersKey) + defaults.set(unique(hiddenHeadphones + hiddenSpeakers), forKey: hiddenOutputsKey) + } + + private func unique(_ uids: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + for uid in uids where !uid.isEmpty && !seen.contains(uid) { + seen.insert(uid) + result.append(uid) + } + return result + } + + private func sortDevices(_ devices: [AudioDevice], type: AudioDeviceType) -> [AudioDevice] { + let priorities = priorityUIDs(type: type) + let priorityIndexes = Dictionary(uniqueKeysWithValues: priorities.enumerated().map { ($1, $0) }) + let knownIndexes = Dictionary(uniqueKeysWithValues: knownUIDs(type: type).enumerated().map { ($1, $0) }) return devices.sorted { a, b in - let indexA = priorities.firstIndex(of: a.uid) ?? Int.max - let indexB = priorities.firstIndex(of: b.uid) ?? Int.max - return indexA < indexB + let priorityA = priorityIndexes[a.uid] ?? Int.max + let priorityB = priorityIndexes[b.uid] ?? Int.max + if priorityA != priorityB { + return priorityA < priorityB + } + + let knownA = knownIndexes[a.uid] ?? Int.max + let knownB = knownIndexes[b.uid] ?? Int.max + if knownA != knownB { + return knownA < knownB + } + + if a.uid != b.uid { + return a.uid < b.uid + } + return a.id < b.id + } + } + + private func initializePriorityConfigurationIfNeeded(type: AudioDeviceType) { + let configuredKey = priorityConfiguredKey(for: type) + if defaults.object(forKey: configuredKey) == nil { + guard !priorityUIDs(type: type).isEmpty else { return } + defaults.set(true, forKey: configuredKey) + } + + guard defaults.bool(forKey: configuredKey) else { return } + let priorities = priorityUIDs(type: type) + let completeCanonical = unique(priorities + knownUIDs(type: type)) + if completeCanonical != priorities { + saveCanonical(completeCanonical, type: type) } } - private func savePriorities(_ devices: [AudioDevice], key: String) { - let uids = devices.map { $0.uid } - defaults.set(uids, forKey: key) + private func setPriorityConfigured(_ configured: Bool, type: AudioDeviceType) { + defaults.set(configured, forKey: priorityConfiguredKey(for: type)) + } + + private func saveCanonical(_ uids: [String], type: AudioDeviceType) { + defaults.set(unique(uids), forKey: priorityKey(for: type)) + } + + private func knownUIDs(type: AudioDeviceType) -> [String] { + let isInput = type == .input + return unique(getKnownDevices().filter { $0.isInput == isInput }.map(\.uid)) + } + + private func deviceUIDs(_ devices: [AudioDevice], type: AudioDeviceType) -> [String] { + unique(devices.filter { $0.type == type }.map(\.uid)) + } + + private func storedStringList(forKey key: String) -> [String] { + let values = defaults.array(forKey: key) ?? [] + return unique(values.compactMap { $0 as? String }) + } + + private func removeRoleState(uid: String, type: AudioDeviceType) { + remove(uid: uid, fromListForKey: priorityKey(for: type)) + remove(uid: uid, fromListForKey: autoUseDisabledKey(for: type)) + let hiddenKey = type == .input ? hiddenMicsKey : hiddenOutputsKey + remove(uid: uid, fromListForKey: hiddenKey) + } + + private func remove(uid: String, fromListForKey key: String) { + var list = storedStringList(forKey: key) + list.removeAll { $0 == uid } + defaults.set(list, forKey: key) } } diff --git a/AudioPriorityBar/Views/AdaptiveDeviceListLayout.swift b/AudioPriorityBar/Views/AdaptiveDeviceListLayout.swift new file mode 100644 index 0000000..5e5cbee --- /dev/null +++ b/AudioPriorityBar/Views/AdaptiveDeviceListLayout.swift @@ -0,0 +1,57 @@ +import Foundation + +struct AdaptiveDeviceListHeights: Equatable { + let output: CGFloat + let input: CGFloat +} + +enum AdaptiveDeviceListLayout { + static let maximumPreferredMenuHeight: CGFloat = 640 + static let screenEdgeMargin: CGFloat = 24 + static let estimatedFixedChromeHeight: CGFloat = 296 + static let dividerHeight: CGFloat = 1 + static let emptyStateHeight: CGFloat = 38 + static let rowHeight: CGFloat = 42 + static let rowSpacing: CGFloat = 4 + static let listVerticalPadding: CGFloat = 8 + + static func requiredHeight(deviceCount: Int) -> CGFloat { + guard deviceCount > 0 else { return emptyStateHeight } + return CGFloat(deviceCount) * rowHeight + + CGFloat(max(0, deviceCount - 1)) * rowSpacing + + listVerticalPadding + } + + static func allocate( + outputCount: Int, + inputCount: Int, + availableHeight: CGFloat + ) -> AdaptiveDeviceListHeights { + let budget = max(0, availableHeight) + let outputNeed = requiredHeight(deviceCount: outputCount) + let inputNeed = requiredHeight(deviceCount: inputCount) + + guard outputNeed + inputNeed > budget else { + return AdaptiveDeviceListHeights(output: outputNeed, input: inputNeed) + } + + let half = budget / 2 + if outputNeed <= half { + return AdaptiveDeviceListHeights( + output: outputNeed, + input: min(inputNeed, budget - outputNeed) + ) + } + if inputNeed <= half { + return AdaptiveDeviceListHeights( + output: min(outputNeed, budget - inputNeed), + input: inputNeed + ) + } + + return AdaptiveDeviceListHeights( + output: min(outputNeed, half), + input: min(inputNeed, budget - half) + ) + } +} diff --git a/AudioPriorityBar/Views/DeviceListView.swift b/AudioPriorityBar/Views/DeviceListView.swift index 4077372..2ecbfaf 100644 --- a/AudioPriorityBar/Views/DeviceListView.swift +++ b/AudioPriorityBar/Views/DeviceListView.swift @@ -1,44 +1,36 @@ import SwiftUI import CoreAudio -import UniformTypeIdentifiers struct DeviceListView: View { let devices: [AudioDevice] let currentDeviceId: AudioObjectID? let onMove: (IndexSet, Int) -> Void let onSelect: (AudioDevice) -> Void - var showCategoryPicker: Bool = false + let onPromote: (AudioDevice) -> Void + let selectionChangesPriority: Bool var onHide: ((AudioDevice) -> Void)? var onUnhide: ((AudioDevice) -> Void)? - var isHiddenSection: Bool = false - var category: OutputCategory? = nil // Only track which item is being dragged and the target - not the offset @State private var draggingIndex: Int? = nil @State private var targetIndex: Int? = nil + @State private var dragSnapshot: [String] = [] private let rowHeight: CGFloat = 32 var body: some View { VStack(spacing: 4) { - ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in + ForEach(Array(devices.enumerated()), id: \.element.roleIdentifier) { index, device in DraggableDeviceRow( device: device, index: index, totalCount: devices.count, isSelected: device.id == currentDeviceId, onSelect: { onSelect(device) }, - showCategoryPicker: showCategoryPicker, + selectionChangesPriority: selectionChangesPriority, + onPromote: { onPromote(device) }, onHide: onHide, onUnhide: onUnhide, - isHiddenSection: isHiddenSection, - category: category, - onMoveUp: index > 0 ? { - onMove(IndexSet(integer: index), index - 1) - } : nil, - onMoveDown: index < devices.count - 1 ? { - onMove(IndexSet(integer: index), index + 2) - } : nil, isDragging: draggingIndex == index, isDropTarget: isDropTarget(for: index), isDropTargetBelow: isDropTargetBelow(for: index), @@ -46,6 +38,7 @@ struct DeviceListView: View { deviceCount: devices.count, onDragStarted: { draggingIndex = index + dragSnapshot = devices.map(\.roleIdentifier) }, onTargetChanged: { newTarget in targetIndex = newTarget @@ -57,6 +50,11 @@ struct DeviceListView: View { .zIndex(draggingIndex == index ? 100 : 0) } } + .onChange(of: devices.map(\.roleIdentifier)) { identifiers in + if !dragSnapshot.isEmpty && identifiers != dragSnapshot { + resetDragState() + } + } } private func isDropTarget(for index: Int) -> Bool { @@ -70,11 +68,24 @@ struct DeviceListView: View { } private func performMove(fromIndex: Int) { - if let target = targetIndex, target != fromIndex { + let currentIdentifiers = devices.map(\.roleIdentifier) + guard dragSnapshot == currentIdentifiers, + devices.indices.contains(fromIndex) else { + resetDragState() + return + } + if let target = targetIndex, + target != fromIndex, + (0...devices.count).contains(target) { onMove(IndexSet(integer: fromIndex), target) } + resetDragState() + } + + private func resetDragState() { draggingIndex = nil targetIndex = nil + dragSnapshot = [] } } @@ -86,13 +97,10 @@ struct DraggableDeviceRow: View { var totalCount: Int = 1 let isSelected: Bool let onSelect: () -> Void - var showCategoryPicker: Bool = false + let selectionChangesPriority: Bool + let onPromote: () -> Void var onHide: ((AudioDevice) -> Void)? var onUnhide: ((AudioDevice) -> Void)? - var isHiddenSection: Bool = false - var category: OutputCategory? = nil - var onMoveUp: (() -> Void)? - var onMoveDown: (() -> Void)? let isDragging: Bool var isDropTarget: Bool = false var isDropTargetBelow: Bool = false @@ -110,15 +118,15 @@ struct DraggableDeviceRow: View { } var isIgnored: Bool { - audioManager.isDeviceIgnored(device, inCategory: category) + audioManager.isDeviceIgnored(device) } var isGrayed: Bool { - isDisconnected || isHiddenSection + isDisconnected } - var isNeverUse: Bool { - audioManager.isNeverUse(device) + var isAutoUseDisabled: Bool { + audioManager.isAutoUseDisabled(device) } var statusIcon: String? { @@ -126,7 +134,7 @@ struct DraggableDeviceRow: View { return "wifi.slash" } else if isIgnored && audioManager.isEditMode { return "eye.slash" - } else if isNeverUse { + } else if isAutoUseDisabled { return "nosign" } return nil @@ -134,7 +142,7 @@ struct DraggableDeviceRow: View { var lastSeenText: String? { guard isDisconnected, - let stored = audioManager.priorityManager.getStoredDevice(uid: device.uid) else { + let stored = audioManager.priorityManager.getStoredDevice(for: device) else { return nil } return stored.lastSeenRelative @@ -155,47 +163,64 @@ struct DraggableDeviceRow: View { return newTarget } + private var reorderGesture: some Gesture { + DragGesture(minimumDistance: 5) + .onChanged { value in + if !isDragging { + onDragStarted() + } + let newTarget = calculateTarget(offset: value.translation.height) + if newTarget != lastReportedTarget { + lastReportedTarget = newTarget + onTargetChanged(newTarget) + } + } + .onEnded { _ in + lastReportedTarget = nil + onDragEnded() + } + } + var body: some View { HStack(spacing: 8) { // Drag handle + priority label area - if !isHiddenSection { - ZStack { - // Drag handle icon - Image(systemName: "line.3.horizontal") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.secondary) - .frame(width: 36, height: rowHeight) - .opacity(isHovering || isDragging ? 1 : 0) - .scaleEffect(isHovering || isDragging ? 1 : 0.8) - - // Priority number or "Active" label when not hovering - Group { - if isSelected && !isDisconnected { - Text("Active") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(.accentColor) - } else { - Text("\(index + 1)") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.secondary.opacity(0.8)) - } + ZStack { + // Drag handle icon + Image(systemName: "line.3.horizontal") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 36, height: rowHeight) + .opacity(isHovering || isDragging ? 1 : 0) + .scaleEffect(isHovering || isDragging ? 1 : 0.8) + + // Priority number or "Active" label when not hovering + Group { + if isSelected && !isDisconnected { + Text("Active") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.accentColor) + } else { + Text("\(index + 1)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.secondary.opacity(0.8)) } - .opacity(isHovering || isDragging ? 0 : 1) - .scaleEffect(isHovering || isDragging ? 0.8 : 1) } - .frame(width: 36) - .animation(.easeInOut(duration: 0.12), value: isHovering) - .animation(.easeInOut(duration: 0.12), value: isDragging) + .opacity(isHovering || isDragging ? 0 : 1) + .scaleEffect(isHovering || isDragging ? 0.8 : 1) } + .frame(width: 36) + .contentShape(Rectangle()) + .gesture(reorderGesture) + .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: isDragging) // Device name - use HStack with tap gesture instead of Button to not interfere with drag HStack(spacing: 8) { Text(device.name) .font(.system(size: 13, weight: .regular)) - .strikethrough(isNeverUse, color: .secondary) .lineLimit(1) .truncationMode(.tail) - .foregroundColor(isGrayed || isNeverUse ? .secondary : .primary) + .foregroundColor(isGrayed ? .secondary : .primary) if let icon = statusIcon { Image(systemName: icon) @@ -203,6 +228,15 @@ struct DraggableDeviceRow: View { .foregroundColor(.secondary.opacity(0.7)) } + if isAutoUseDisabled { + Text("Auto-use off") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(Color.primary.opacity(0.08))) + } + if let lastSeen = lastSeenText { Text(lastSeen) .font(.system(size: 10)) @@ -241,23 +275,13 @@ struct DraggableDeviceRow: View { if isHovering && !isDragging { Group { Menu { - if showCategoryPicker { - Button { - audioManager.setCategory(.speaker, for: device) - } label: { - Label("Move to Speakers", systemImage: "speaker.wave.2.fill") - } - Button { - audioManager.setCategory(.headphone, for: device) - } label: { - Label("Move to Headphones", systemImage: "headphones") - } - Divider() - } - - if isHiddenSection || isIgnored { + if isIgnored { Button { - audioManager.unhideDevice(device) + if let onUnhide { + onUnhide(device) + } else { + audioManager.unhideDevice(device) + } } label: { Label("Stop Ignoring", systemImage: "eye") } @@ -266,17 +290,8 @@ struct DraggableDeviceRow: View { Button { onHide(device) } label: { - let categoryLabel = device.type == .input ? "microphone" : - (category == .headphone ? "headphone" : "speaker") - Label("Ignore as \(categoryLabel)", systemImage: "eye.slash") - } - - if device.type == .output { - Button { - audioManager.hideDeviceEntirely(device) - } label: { - Label("Ignore entirely", systemImage: "eye.slash.fill") - } + let role = device.type == .input ? "input" : "output" + Label("Ignore as \(role)", systemImage: "eye.slash") } } } @@ -284,22 +299,22 @@ struct DraggableDeviceRow: View { if isDisconnected { Divider() Button(role: .destructive) { - audioManager.priorityManager.forgetDevice(device.uid) - audioManager.refreshDevices() + audioManager.forgetDevice(device) } label: { Label("Forget Device", systemImage: "trash") } } - if device.isConnected { + if device.isConnected || audioManager.isEditMode { Divider() Button { - audioManager.setNeverUse(device, neverUse: !audioManager.isNeverUse(device)) + audioManager.setAutoUseDisabled(device, disabled: !audioManager.isAutoUseDisabled(device)) } label: { - if audioManager.isNeverUse(device) { - Label("Allow Use", systemImage: "checkmark.circle") + let role = device.type == .input ? "input" : "output" + if audioManager.isAutoUseDisabled(device) { + Label("Allow auto-use as \(role)", systemImage: "checkmark.circle") } else { - Label("Never Use", systemImage: "nosign") + Label("Never use as \(role)", systemImage: "nosign") } } } @@ -364,27 +379,15 @@ struct DraggableDeviceRow: View { .animation(.easeInOut(duration: 0.1), value: isDropTargetBelow) .contentShape(Rectangle()) .onTapGesture { - if !isDisconnected && audioManager.isCustomMode { + if isDisconnected { + return + } + if selectionChangesPriority { + onPromote() + } else { onSelect() } } - .gesture( - DragGesture(minimumDistance: 5) - .onChanged { value in - if !isDragging { - onDragStarted() - } - let newTarget = calculateTarget(offset: value.translation.height) - if newTarget != lastReportedTarget { - lastReportedTarget = newTarget - onTargetChanged(newTarget) - } - } - .onEnded { _ in - lastReportedTarget = nil - onDragEnded() - } - ) } } diff --git a/AudioPriorityBar/Views/MenuBarView.swift b/AudioPriorityBar/Views/MenuBarView.swift index 3ad55c2..8b83832 100644 --- a/AudioPriorityBar/Views/MenuBarView.swift +++ b/AudioPriorityBar/Views/MenuBarView.swift @@ -2,105 +2,143 @@ import SwiftUI import CoreAudio import AppKit +private enum MenuChromePart: Hashable { + case guidance + case outputSection + case inputSection + case footer +} + +private struct MenuChromeHeightPreferenceKey: PreferenceKey { + static var defaultValue: [MenuChromePart: CGFloat] = [:] + + static func reduce( + value: inout [MenuChromePart: CGFloat], + nextValue: () -> [MenuChromePart: CGFloat] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, newValue in newValue }) + } +} + +private extension View { + func reportMenuChromeHeight(_ part: MenuChromePart, additionalHeight: CGFloat = 0) -> some View { + background { + GeometryReader { proxy in + Color.clear.preference( + key: MenuChromeHeightPreferenceKey.self, + value: [part: proxy.size.height + additionalHeight] + ) + } + } + } +} + struct MenuBarView: View { @EnvironmentObject var audioManager: AudioManager + @State private var measuredChromeHeights: [MenuChromePart: CGFloat] = [:] + + private var fixedChromeHeight: CGFloat { + let parts: [MenuChromePart] = [.guidance, .outputSection, .inputSection, .footer] + guard parts.allSatisfy({ measuredChromeHeights[$0] != nil }) else { + return AdaptiveDeviceListLayout.estimatedFixedChromeHeight + } + let measuredParts = parts.reduce(CGFloat(0)) { total, part in + total + (measuredChromeHeights[part] ?? 0) + } + return measuredParts + AdaptiveDeviceListLayout.dividerHeight * 3 + } + + private var listHeights: AdaptiveDeviceListHeights { + AdaptiveDeviceListLayout.allocate( + outputCount: audioManager.outputDevices.count, + inputCount: audioManager.inputDevices.count, + availableHeight: max(0, maximumMenuHeight - fixedChromeHeight) + ) + } + + private var maximumMenuHeight: CGFloat { + let pointerLocation = NSEvent.mouseLocation + let screen = NSScreen.screens.first { NSMouseInRect(pointerLocation, $0.frame, false) } + ?? NSScreen.main + let visibleHeight = screen?.visibleFrame.height ?? AdaptiveDeviceListLayout.maximumPreferredMenuHeight + return min( + AdaptiveDeviceListLayout.maximumPreferredMenuHeight, + max(0, visibleHeight - AdaptiveDeviceListLayout.screenEdgeMargin) + ) + } + + private var menuHeight: CGFloat { + min(maximumMenuHeight, fixedChromeHeight + listHeights.output + listHeights.input) + } var body: some View { VStack(spacing: 0) { - // Header with mode toggle and volume - VStack(spacing: 14) { - ModeToggleView() - VolumeSliderView() + VStack(alignment: .leading, spacing: 3) { + Text("Rank devices from highest to lowest.") + Text("Auto-switch uses the highest connected device.") } + .font(.system(size: 12)) + .foregroundColor(.secondary) .padding(.horizontal, 16) - .padding(.vertical, 14) - .background(Color.primary.opacity(0.02)) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .reportMenuChromeHeight(.guidance) Divider() .padding(.horizontal, 12) - ScrollView { - VStack(spacing: 20) { - // Speakers (show in speaker mode or custom mode) - if audioManager.currentMode == .speaker || audioManager.isCustomMode { - DeviceSectionView( - title: "Speakers", - icon: "speaker.wave.2.fill", - devices: audioManager.speakerDevices, - currentDeviceId: audioManager.currentOutputId, - onMove: audioManager.moveSpeakerDevice, - onSelect: { device in - if !audioManager.isCustomMode { - audioManager.setMode(.speaker) - } - audioManager.setOutputDevice(device) - }, - onHide: { audioManager.hideDevice($0, category: .speaker) }, - onUnhide: { audioManager.unhideDevice($0, category: .speaker) }, - category: .speaker, - showCategoryPicker: true, - isActiveCategory: audioManager.currentMode == .speaker || audioManager.isCustomMode - ) - } + DeviceSectionView( + type: .output, + title: "Output", + icon: "speaker.wave.2.fill", + helperText: "Uses the highest connected output", + devices: audioManager.outputDevices, + currentDeviceId: audioManager.currentOutputId, + listHeight: listHeights.output, + isAutoSwitchEnabled: audioManager.isOutputAutoSwitchEnabled, + onAutoSwitchChanged: audioManager.setOutputAutoSwitchEnabled, + onMove: audioManager.moveOutputDevice, + onSelect: audioManager.setOutputDevice, + onPromote: audioManager.promoteOutputDevice, + onHide: audioManager.hideDevice, + onUnhide: audioManager.unhideDevice, + showsOutputControls: true + ) - // Headphones (show in headphone mode or custom mode) - if audioManager.currentMode == .headphone || audioManager.isCustomMode { - DeviceSectionView( - title: "Headphones", - icon: "headphones", - devices: audioManager.headphoneDevices, - currentDeviceId: audioManager.currentOutputId, - onMove: audioManager.moveHeadphoneDevice, - onSelect: { device in - if !audioManager.isCustomMode { - audioManager.setMode(.headphone) - } - audioManager.setOutputDevice(device) - }, - onHide: { audioManager.hideDevice($0, category: .headphone) }, - onUnhide: { audioManager.unhideDevice($0, category: .headphone) }, - category: .headphone, - showCategoryPicker: true, - isActiveCategory: audioManager.currentMode == .headphone || audioManager.isCustomMode - ) - } + Divider() + .padding(.horizontal, 12) - // Microphones (always shown, at the bottom) - DeviceSectionView( - title: "Microphones", - icon: "mic.fill", - devices: audioManager.inputDevices, - currentDeviceId: audioManager.currentInputId, - onMove: audioManager.moveInputDevice, - onSelect: audioManager.setInputDevice, - onHide: { audioManager.hideDevice($0, category: nil) }, - onUnhide: { audioManager.unhideDevice($0, category: nil) }, - category: nil, - showCategoryPicker: false - ) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - .frame(maxHeight: 420) + DeviceSectionView( + type: .input, + title: "Input", + icon: "mic.fill", + helperText: "Uses the highest connected input", + devices: audioManager.inputDevices, + currentDeviceId: audioManager.currentInputId, + listHeight: listHeights.input, + isAutoSwitchEnabled: audioManager.isInputAutoSwitchEnabled, + onAutoSwitchChanged: audioManager.setInputAutoSwitchEnabled, + onMove: audioManager.moveInputDevice, + onSelect: audioManager.setInputDevice, + onPromote: audioManager.promoteInputDevice, + onHide: audioManager.hideDevice, + onUnhide: audioManager.unhideDevice + ) Divider() .padding(.horizontal, 12) - // Footer - HStack(spacing: 16) { - // Hidden devices toggle (only in normal mode) - if !audioManager.isEditMode { - HiddenDevicesToggleView() - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } + HStack(spacing: 12) { + AutomationMenuView() + + Spacer(minLength: 4) - Spacer() - - // Launch at login toggle LaunchAtLoginToggle() - // Edit mode toggle + Divider() + .frame(height: 18) + Button { withAnimation(.easeInOut(duration: 0.2)) { audioManager.toggleEditMode() @@ -110,14 +148,16 @@ struct MenuBarView: View { Image(systemName: audioManager.isEditMode ? "checkmark.circle.fill" : "pencil.circle") .font(.system(size: 12)) Text(audioManager.isEditMode ? "Done" : "Edit") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 11, weight: .medium)) } .foregroundColor(audioManager.isEditMode ? .accentColor : .secondary) } .buttonStyle(.plain) .animation(.easeInOut(duration: 0.2), value: audioManager.isEditMode) - // Quit button + Divider() + .frame(height: 18) + Button { NSApplication.shared.terminate(nil) } label: { @@ -130,75 +170,54 @@ struct MenuBarView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) + .fixedSize(horizontal: false, vertical: true) + .reportMenuChromeHeight(.footer) .animation(.easeInOut(duration: 0.2), value: audioManager.isEditMode) } + .onPreferenceChange(MenuChromeHeightPreferenceKey.self) { heights in + if heights != measuredChromeHeights { + measuredChromeHeights = heights + } + } .frame(width: 340) + .frame(height: menuHeight, alignment: .top) } } -struct ModeToggleView: View { +struct AutomationMenuView: View { @EnvironmentObject var audioManager: AudioManager var body: some View { - HStack(spacing: 4) { - ForEach(OutputCategory.allCases, id: \.self) { mode in - let isSelected = audioManager.currentMode == mode && !audioManager.isCustomMode + Menu { + if audioManager.isAutomationPaused { Button { - withAnimation(.easeInOut(duration: 0.2)) { - if audioManager.isCustomMode { - audioManager.setCustomMode(false) - } - audioManager.setMode(mode) - } + audioManager.setAutomationPaused(false) } label: { - HStack(spacing: 5) { - Image(systemName: mode.icon) - .font(.system(size: 11)) - Text(mode.label) - .font(.system(size: 12, weight: .medium)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected ? Color.accentColor : Color.clear) - ) - .foregroundColor(isSelected ? .white : .secondary) + Label("Resume all auto-switching", systemImage: "play.circle") } - .buttonStyle(.plain) - } - - // Custom mode toggle - Button { - withAnimation(.easeInOut(duration: 0.2)) { - audioManager.setCustomMode(!audioManager.isCustomMode) + Text("Restores Output and Input") + } else { + Button { + audioManager.setAutomationPaused(true) + } label: { + Label("Pause all auto-switching", systemImage: "pause.circle") } - } label: { - Image(systemName: "hand.raised.fill") - .font(.system(size: 12)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(audioManager.isCustomMode ? Color.orange : Color.clear) - ) - .foregroundColor(audioManager.isCustomMode ? .white : .secondary) + Text("Stops Output and Input") } - .buttonStyle(.plain) - .help("Manual mode - disable auto-switching") + } label: { + HStack(spacing: 5) { + Image(systemName: audioManager.isAutomationPaused ? "pause.circle.fill" : "gearshape.2") + .font(.system(size: 11)) + Text(audioManager.isAutomationPaused ? "Paused" : "Automation") + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(audioManager.isAutomationPaused ? .orange : .secondary) } - .padding(4) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.primary.opacity(0.05)) - ) - .animation(.easeInOut(duration: 0.2), value: audioManager.currentMode) - .animation(.easeInOut(duration: 0.2), value: audioManager.isCustomMode) + .menuStyle(.borderlessButton) + .fixedSize() + .help(audioManager.isAutomationPaused ? "Auto-switching is paused" : "Automation options") + .accessibilityLabel(Text("Automation")) + .accessibilityValue(Text(audioManager.isAutomationPaused ? "Paused" : "Running")) } } @@ -206,18 +225,14 @@ struct VolumeSliderView: View { @EnvironmentObject var audioManager: AudioManager var volumeIcon: String { - if audioManager.currentMode == .headphone { - return "headphones" + if audioManager.volume <= 0 { + return "speaker.fill" + } else if audioManager.volume < 0.33 { + return "speaker.wave.1.fill" + } else if audioManager.volume < 0.66 { + return "speaker.wave.2.fill" } else { - if audioManager.volume <= 0 { - return "speaker.fill" - } else if audioManager.volume < 0.33 { - return "speaker.wave.1.fill" - } else if audioManager.volume < 0.66 { - return "speaker.wave.2.fill" - } else { - return "speaker.wave.3.fill" - } + return "speaker.wave.3.fill" } } @@ -290,153 +305,114 @@ extension View { } struct DeviceSectionView: View { + @EnvironmentObject var audioManager: AudioManager + let type: AudioDeviceType let title: String let icon: String + let helperText: String let devices: [AudioDevice] let currentDeviceId: AudioObjectID? + let listHeight: CGFloat + let isAutoSwitchEnabled: Bool + let onAutoSwitchChanged: (Bool) -> Void let onMove: (IndexSet, Int) -> Void let onSelect: (AudioDevice) -> Void + let onPromote: (AudioDevice) -> Void var onHide: ((AudioDevice) -> Void)? var onUnhide: ((AudioDevice) -> Void)? - var category: OutputCategory? - var showCategoryPicker: Bool = false - var isActiveCategory: Bool = true - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 11)) - .foregroundColor(isActiveCategory ? .accentColor : .secondary) - Text(title) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.secondary) - .textCase(.uppercase) - .tracking(0.5) - } - - if devices.isEmpty { - Text("No devices") - .font(.system(size: 13)) - .foregroundColor(.secondary.opacity(0.7)) - .italic() - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - DeviceListView( - devices: devices, - currentDeviceId: currentDeviceId, - onMove: onMove, - onSelect: onSelect, - showCategoryPicker: showCategoryPicker, - onHide: onHide, - onUnhide: onUnhide, - category: category - ) - } - } - } -} - -struct HiddenDevicesToggleView: View { - @EnvironmentObject var audioManager: AudioManager - @State private var isExpanded = false + var showsOutputControls: Bool = false - var allHiddenDevices: [AudioDevice] { - audioManager.hiddenInputDevices + - audioManager.hiddenSpeakerDevices + - audioManager.hiddenHeadphoneDevices + private var accentColor: Color { + type == .input ? .green : .accentColor } var body: some View { - if allHiddenDevices.isEmpty { - Text("") - .frame(height: 1) - } else { - Button { - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } label: { - HStack(spacing: 5) { - Image(systemName: "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - Image(systemName: "eye.slash") - .font(.system(size: 11)) - Text("\(allHiddenDevices.count) ignored") - .font(.system(size: 12)) - } - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - .popover(isPresented: $isExpanded, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 4) { - ForEach(allHiddenDevices, id: \.id) { device in - HiddenDeviceRow(device: device) - } + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(accentColor) + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(accentColor) + .textCase(.uppercase) + .tracking(0.5) + + Spacer() + + Toggle( + "Auto-switch", + isOn: Binding( + get: { isAutoSwitchEnabled }, + set: onAutoSwitchChanged + ) + ) + .font(.system(size: 11)) + .toggleStyle(.switch) + .controlSize(.small) + .tint(accentColor) + .disabled(audioManager.isAutomationPaused) + .help( + audioManager.isAutomationPaused + ? "Resume all auto-switching to change this setting" + : "Automatically use the highest connected \(title.lowercased())" + ) } - .padding(12) - .frame(minWidth: 220) - } - } - } -} -struct HiddenDeviceRow: View { - @EnvironmentObject var audioManager: AudioManager - let device: AudioDevice - @State private var isHovering = false - - var deviceIcon: String { - if device.type == .input { - return "mic.fill" - } else { - let category = audioManager.priorityManager.getCategory(for: device) - return category == .headphone ? "headphones" : "speaker.wave.2.fill" - } - } - - var body: some View { - HStack(spacing: 10) { - Image(systemName: deviceIcon) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .frame(width: 18) - - Text(device.name) - .font(.system(size: 13)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() + Text(helperText) + .font(.system(size: 11)) + .foregroundColor(.secondary) - if isHovering { - Button { - audioManager.unhideDevice(device) - } label: { - Image(systemName: "eye") - .font(.system(size: 13)) - .foregroundColor(.secondary) + if showsOutputControls { + VolumeSliderView() } - .buttonStyle(.plain) - .help("Stop ignoring") - .transition(.opacity.combined(with: .scale(scale: 0.8))) } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear) - ) - .animation(.easeInOut(duration: 0.15), value: isHovering) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovering = hovering + .fixedSize(horizontal: false, vertical: true) + .reportMenuChromeHeight( + type == .output ? .outputSection : .inputSection, + additionalHeight: 32 + ) + + ScrollView(.vertical) { + if devices.isEmpty { + Text("No devices") + .font(.system(size: 12)) + .foregroundColor(.secondary.opacity(0.7)) + .italic() + .padding(.horizontal, 10) + .frame( + maxWidth: .infinity, + minHeight: AdaptiveDeviceListLayout.emptyStateHeight, + alignment: .leading + ) + } else { + DeviceListView( + devices: devices, + currentDeviceId: currentDeviceId, + onMove: onMove, + onSelect: onSelect, + onPromote: onPromote, + selectionChangesPriority: audioManager.isAutomationActive(for: type), + onHide: onHide, + onUnhide: onUnhide + ) + .padding(.vertical, AdaptiveDeviceListLayout.listVerticalPadding / 2) + } } + .frame(height: listHeight) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.primary.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary.opacity(0.09), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) } + .padding(.horizontal, 16) + .padding(.vertical, 12) } } diff --git a/AudioPriorityBarTests/PriorityManagerTests.swift b/AudioPriorityBarTests/PriorityManagerTests.swift new file mode 100644 index 0000000..48ced63 --- /dev/null +++ b/AudioPriorityBarTests/PriorityManagerTests.swift @@ -0,0 +1,279 @@ +import CoreAudio +import XCTest + +final class PriorityManagerTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + private var manager: PriorityManager! + + override func setUp() { + super.setUp() + suiteName = "AudioPriorityBarTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + manager = PriorityManager(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + manager = nil + defaults = nil + suiteName = nil + super.tearDown() + } + + private func device( + _ uid: String, + type: AudioDeviceType = .input, + connected: Bool = true + ) -> AudioDevice { + AudioDevice(id: connected ? AudioObjectID(abs(uid.hashValue % 10_000) + 1) : 0, + uid: uid, + name: uid, + type: type, + isConnected: connected) + } + + private func remember(_ uid: String, type: AudioDeviceType = .input) { + manager.rememberDevice(uid, name: uid, isInput: type == .input) + } + + func testDiscoveryDoesNotConfigureNewInstall() { + remember("A") + remember("B") + + manager.registerDiscoveredUIDs(["B", "A"], type: .input) + + XCTAssertFalse(manager.isPriorityConfigured(type: .input)) + XCTAssertFalse(manager.hasPriorities(type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), []) + } + + func testExistingSavedPrioritiesInitializeConfiguredRole() { + defaults.set(["B", "A"], forKey: "inputPriorities") + + manager = PriorityManager(defaults: defaults) + + XCTAssertTrue(manager.isPriorityConfigured(type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["B", "A"]) + } + + func testLegacyKnownDevicesAreCanonicalizedBeforeNewDiscovery() { + remember("A") + remember("B") + defaults.set(["A"], forKey: "inputPriorities") + + manager = PriorityManager(defaults: defaults) + manager.registerDiscoveredUIDs(["C"], type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["A", "B", "C"]) + } + + func testFirstExplicitMergeSeedsVisibleOrderThenRemainingKnownDevices() { + remember("A") + remember("B") + remember("C") + + manager.mergeVisibleOrder([device("C"), device("B")], type: .input) + + XCTAssertTrue(manager.isPriorityConfigured(type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["C", "B", "A"]) + } + + func testVisibleMergePreservesMultipleAbsentSlots() { + let all = ["A", "H1", "B", "H2", "C"].map { device($0) } + manager.mergeVisibleOrder(all, type: .input) + + manager.mergeVisibleOrder([device("C"), device("B"), device("A")], type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["C", "H1", "B", "H2", "A"]) + } + + func testEmptyVisibleMergeDoesNotClearCanonicalOrder() { + manager.mergeVisibleOrder([device("A"), device("B")], type: .input) + + manager.mergeVisibleOrder([], type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["A", "B"]) + } + + func testAutoUseOffDeviceStillParticipatesWhenVisible() { + let a = device("A") + let b = device("B") + let c = device("C") + manager.mergeVisibleOrder([a, b, c], type: .input) + manager.setAutoUseDisabled(b, disabled: true) + + manager.mergeVisibleOrder([c, b, a], type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["C", "B", "A"]) + XCTAssertTrue(manager.isAutoUseDisabled(b)) + } + + func testPromoteMovesDeviceToAbsoluteFirstAcrossAbsentDevices() { + let a = device("A") + let b = device("B") + let c = device("C") + manager.mergeVisibleOrder([a, b, c], type: .input) + + manager.promote(c, visibleDevices: [b, c]) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["C", "A", "B"]) + } + + func testFirstPromoteConfiguresRoleWithoutEnumerationBasedDiscovery() { + remember("A") + remember("B") + let b = device("B") + + manager.promote(b, visibleDevices: [device("A"), b]) + + XCTAssertTrue(manager.isPriorityConfigured(type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["B", "A"]) + } + + func testConfiguredRoleAppendsNewUIDsDeterministically() { + manager.mergeVisibleOrder([device("A")], type: .input) + + manager.registerDiscoveredUIDs(["C", "B", "C"], type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["A", "B", "C"]) + } + + func testInputAndOutputCanonicalOrdersAreIndependentForSharedUID() { + let input = device("shared", type: .input) + let output = device("shared", type: .output) + manager.mergeVisibleOrder([input, device("input-2")], type: .input) + manager.mergeVisibleOrder([device("output-2", type: .output), output], type: .output) + + manager.promote(input, visibleDevices: [input, device("input-2")]) + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["shared", "input-2"]) + XCTAssertEqual(manager.priorityUIDs(type: .output), ["output-2", "shared"]) + } + + func testCanonicalReorderRequiresCompleteCurrentUniverse() { + ["A", "B", "C"].forEach { remember($0) } + manager.mergeVisibleOrder([device("A"), device("B"), device("C")], type: .input) + + XCTAssertFalse(manager.reorderCanonical([device("C"), device("A")], type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["A", "B", "C"]) + XCTAssertTrue(manager.reorderCanonical([device("C"), device("A"), device("B")], type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["C", "A", "B"]) + } + + func testCanonicalOnlyUIDCanBePreservedByCompleteEditReorder() { + defaults.set(["orphan", "known"], forKey: "inputPriorities") + defaults.set(true, forKey: "inputPrioritiesConfigured") + remember("known") + + let reordered = [device("known"), device("orphan", connected: false)] + + XCTAssertTrue(manager.reorderCanonical(reordered, type: .input)) + XCTAssertEqual(manager.priorityUIDs(type: .input), ["known", "orphan"]) + } + + func testForgetRemovesOnlyOneRolesCompleteState() { + let input = device("shared", type: .input) + let output = device("shared", type: .output) + remember("shared", type: .input) + remember("shared", type: .output) + manager.mergeVisibleOrder([input], type: .input) + manager.mergeVisibleOrder([output], type: .output) + manager.hideDevice(input) + manager.hideDevice(output) + manager.setAutoUseDisabled(input, disabled: true) + manager.setAutoUseDisabled(output, disabled: true) + + manager.forgetDevice("shared", type: .input) + + XCTAssertEqual(manager.priorityUIDs(type: .input), []) + XCTAssertFalse(manager.isHidden(input)) + XCTAssertFalse(manager.isAutoUseDisabled(input)) + XCTAssertEqual(manager.priorityUIDs(type: .output), ["shared"]) + XCTAssertTrue(manager.isHidden(output)) + XCTAssertTrue(manager.isAutoUseDisabled(output)) + let known = manager.getKnownDevices() + XCTAssertEqual(known.count, 1) + XCTAssertEqual(known.first?.uid, "shared") + XCTAssertEqual(known.first?.isInput, false) + } + + func testPriorityReadSalvagesStringsAndDeduplicatesCorruptArray() { + defaults.set(["A", 7, "", "A", "B"], forKey: "inputPriorities") + + XCTAssertEqual(manager.priorityUIDs(type: .input), ["A", "B"]) + } + + func testUnrankedDevicesUseKnownOrderThenUIDFallback() { + remember("B") + remember("A") + let devices = [device("C"), device("A"), device("B")] + + XCTAssertEqual(manager.sortByPriority(devices, type: .input).map(\.uid), ["B", "A", "C"]) + } + + func testLegacyOutputMigrationStillSupportsSubsetMerge() { + defaults.removePersistentDomain(forName: suiteName) + defaults.set(["speaker-a", "shared"], forKey: "speakerPriorities") + defaults.set(["headphone-a", "shared"], forKey: "headphonePriorities") + defaults.set("speaker", forKey: "currentMode") + manager = PriorityManager(defaults: defaults) + + manager.mergeVisibleOrder( + [device("headphone-a", type: .output), device("speaker-a", type: .output)], + type: .output + ) + + XCTAssertEqual(manager.priorityUIDs(type: .output), ["headphone-a", "shared", "speaker-a"]) + } +} + +final class DefaultDeviceChangeTrackerTests: XCTestCase { + func testDuplicateCallbacksPreserveManualSelection() { + var tracker = DefaultDeviceChangeTracker() + tracker.preserveManualSelection(11, type: .input) + + XCTAssertTrue(tracker.shouldPreserveManualSelection(currentDeviceId: 11, type: .input)) + XCTAssertTrue(tracker.shouldPreserveManualSelection(currentDeviceId: 11, type: .input)) + } + + func testDifferentDefaultClearsManualSelection() { + var tracker = DefaultDeviceChangeTracker() + tracker.preserveManualSelection(11, type: .input) + + XCTAssertFalse(tracker.shouldPreserveManualSelection(currentDeviceId: 12, type: .input)) + XCTAssertFalse(tracker.shouldPreserveManualSelection(currentDeviceId: 11, type: .input)) + } + + func testRapidManualSelectionsPreserveLatestDeviceAcrossCoalescedCallbacks() { + var tracker = DefaultDeviceChangeTracker() + tracker.preserveManualSelection(11, type: .input) + tracker.preserveManualSelection(12, type: .input) + + XCTAssertTrue(tracker.shouldPreserveManualSelection(currentDeviceId: 12, type: .input)) + XCTAssertTrue(tracker.shouldPreserveManualSelection(currentDeviceId: 12, type: .input)) + } + + func testClearOneRoleDoesNotAffectOppositeRole() { + var tracker = DefaultDeviceChangeTracker() + tracker.preserveManualSelection(11, type: .input) + tracker.preserveManualSelection(22, type: .output) + + tracker.clearManualSelection(type: .input) + + XCTAssertFalse(tracker.shouldPreserveManualSelection(currentDeviceId: 11, type: .input)) + XCTAssertTrue(tracker.shouldPreserveManualSelection(currentDeviceId: 22, type: .output)) + } + + func testDeviceListChangeClearsAllManualSelections() { + var tracker = DefaultDeviceChangeTracker() + tracker.preserveManualSelection(11, type: .input) + tracker.preserveManualSelection(22, type: .output) + + tracker.clearAllManualSelections() + + XCTAssertFalse(tracker.shouldPreserveManualSelection(currentDeviceId: 11, type: .input)) + XCTAssertFalse(tracker.shouldPreserveManualSelection(currentDeviceId: 22, type: .output)) + } +} diff --git a/README.md b/README.md index eea547b..b875aa5 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,24 @@ 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 automatically manages audio device priorities. Set your preferred order for output and input devices - the app automatically switches each role to the highest-priority connected device. ![macOS 13+](https://img.shields.io/badge/macOS-13%2B-blue) ![Swift](https://img.shields.io/badge/Swift-5.9-orange) ![License](https://img.shields.io/badge/license-MIT-green) -![Screenshot](screenshot.jpeg) - ## 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. +- **Priority-based auto-switching**: Devices are ranked by priority. When a higher-priority eligible device connects, it automatically becomes active for that role. +- **Output and input priority lists**: Output devices share one priority list, and input devices share a separate priority list. +- **Independent automation**: Output and Input Auto-switch can be enabled or disabled without affecting the other role. +- **Pause all automation**: Temporarily stop both roles, then resume their unchanged individual settings. +- **Role-specific auto-use controls**: Disable automatic use for input or output without affecting the other role. - **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. -- **Drag-to-reorder**: Reorder devices by dragging or using up/down arrows. +- **Per-role ignore**: Hide a device from the normal list and exclude it from automatic selection for that role. +- **Drag-to-reorder**: Reorder devices by dragging the priority handle. - **Volume control**: Adjust volume with slider or scroll wheel. -- **Menu bar integration**: Shows current mode icon and volume percentage. +- **Menu bar integration**: Shows audio status from the macOS menu bar. ## Installation @@ -50,55 +50,89 @@ Check the [Releases](https://github.com/tobi/AudioPriorityBar/releases) page for ## Usage -### Modes +### Priority Roles + +The menu shows two priority lists: + +- **Output**: All output devices in priority order. Row 1 is the highest-priority output. +- **Input**: All input devices in priority order. Row 1 is the highest-priority input. + +Changing input priority does not change output priority, and changing output priority does not change input priority. -| 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 | +### Common setup: headphones for output, USB microphone for input + +1. Connect your Bluetooth headphones and external USB microphone. +2. In **Output**, drag the Bluetooth headphones to the top of the output priority list. +3. In **Input**, drag the external USB microphone to the top of the input priority list. +4. If the Bluetooth headset microphone appears in **Input**, use its row menu and choose **Never use as input**, or leave it below the external microphone as a fallback. +5. Leave built-in Mac devices lower in their input/output lists so they remain available as fallbacks. +6. If a display, dock, or audio interface exposes both input and output, configure each role separately from its own row/list. ### Managing 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**: When Auto-switch is on for that role, moves the device to the absolute #1 priority and selects it. When Auto-switch is off or all automation is paused, selects it without changing saved priority. +- **Drag devices**: In the normal list, reorders only the visible devices while disconnected or ignored devices keep their saved positions. Edit mode shows the complete remembered order when you need to move a disconnected device relative to connected devices. ### Device Actions (hover 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 +- **Ignore as input/output**: Hide the device and exclude it from automatic selection for that role +- **Never use as input/output**: Keep the device visible and manually selectable, but skip it when auto-selecting that role +- **Allow auto-use as input/output**: Let the app automatically select that role for the device again - **Forget Device**: Remove disconnected device from memory +### Automation controls + +- **Output Auto-switch**: Controls automatic Output selection only. +- **Input Auto-switch**: Controls automatic Input selection only. +- **Pause all auto-switching**: Temporarily stops both Output and Input automation without changing either role's setting. Resume restores the same role settings. + +Each device list scrolls independently when it contains more devices than fit in the menu. Section controls and the footer stay visible. + ### Edit Mode Click "Edit" in the footer to: - See all devices ever connected (disconnected ones grayed out) -- Reorder disconnected devices in the priority list +- See ignored devices and stop ignoring them +- Change Auto-use eligibility for connected or remembered devices +- Reorder the complete saved priority list, including disconnected devices - View "last seen" timestamps - Forget old devices you no longer use ## 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. +2. **Priority Storage**: Each role keeps one complete priority order in UserDefaults, keyed by CoreAudio device UID. Normal-list reordering merges visible devices into that order without deleting disconnected or ignored devices. +3. **Role-Specific Auto-Use**: Input and output auto-use settings are stored separately, so disabling automatic input use does not disable output use for the same device. +4. **Auto-Switching**: When devices connect/disconnect, each enabled role automatically selects its highest-priority connected device that is neither ignored nor set to Auto-use off, unless all automation is paused. + +When upgrading from an older version with global "Never Use" settings, those devices are migrated to auto-use off for both input and output to preserve prior behavior. You can then allow auto-use for input or output independently from the device row menu. + +When upgrading from an older version with separate speaker and headphone output priority lists, those lists are merged into one Output priority list. The list for the previously active mode is placed first, followed by the other list, with duplicates removed. A missing or invalid legacy mode uses the older app's default Speakers mode. Existing unified Output priorities remain authoritative, and all legacy keys are left in place. + +Older Custom/Manual automation settings are migrated to the new role controls while preserving their effective behavior. Legacy preference keys remain in place. + +On a new install, the app waits for you to set a priority list before auto-selecting a role. This avoids changing the current macOS input or output based only on CoreAudio enumeration order. + +After a role has been configured, newly discovered devices are added at the end of its saved order. Forgetting a device removes that role's remembered device record, priority, Ignore state, and Auto-use state without changing the opposite role for hardware that exposes both input and output. ## Project Structure ``` AudioPriorityBar/ -├── AudioPriorityBarApp.swift # App entry, MenuBarExtra, AudioManager +├── AudioPriorityBarApp.swift # App entry and MenuBarExtra ├── Models/ -│ └── AudioDevice.swift # Device model, OutputCategory enum +│ ├── AudioDevice.swift # Device model +│ └── DefaultDeviceChangeTracker.swift # Preserves deliberate manual selections ├── Services/ +│ ├── AudioManager.swift # App state and audio automation │ ├── AudioDeviceService.swift # CoreAudio wrapper │ └── PriorityManager.swift # Priority persistence └── Views/ + ├── AdaptiveDeviceListLayout.swift # Screen-bounded list height allocation ├── MenuBarView.swift # Main popover UI └── DeviceListView.swift # Device list and row components +AudioPriorityBarTests/ +└── PriorityManagerTests.swift # Priority persistence and change-tracking tests ``` ## Contributing