Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ Index/
# xcode-build-server files
buildServer.json
.compile
.claude
20 changes: 20 additions & 0 deletions AudioPriorityBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,25 @@
A10000000000000000000002 /* AudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AudioDevice.swift */; };
A10000000000000000000003 /* AudioDeviceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* AudioDeviceService.swift */; };
A10000000000000000000004 /* PriorityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* PriorityManager.swift */; };
A10000000000000000000011 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* MenuBarController.swift */; };
A10000000000000000000005 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MenuBarView.swift */; };
A10000000000000000000006 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* DeviceListView.swift */; };
A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* LaunchAtLoginManager.swift */; };
A10000000000000000000009 /* Headphones.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Headphones.swift */; };
A10000000000000000000010 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; };
A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* Assets.xcassets */; };
4DD6D82E5CF6D899F9B4F327 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622640DCAA11DD5022FC5DBF /* AppInfo.swift */; };
5EACF86E108BAB90FAC19892 /* PreferencesWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */; };
ED800EC86195D082D84E8604 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */; };
A10000000000000000000012 /* ClickActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* ClickActions.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
A20000000000000000000001 /* AudioPriorityBarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPriorityBarApp.swift; sourceTree = "<group>"; };
A20000000000000000000002 /* AudioDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDevice.swift; sourceTree = "<group>"; };
A20000000000000000000003 /* AudioDeviceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceService.swift; sourceTree = "<group>"; };
A20000000000000000000004 /* PriorityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriorityManager.swift; sourceTree = "<group>"; };
A20000000000000000000011 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = "<group>"; };
A20000000000000000000005 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
A20000000000000000000006 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = "<group>"; };
A20000000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand All @@ -32,6 +38,10 @@
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 = "<group>"; };
A30000000000000000000001 /* AudioPriorityBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPriorityBar.app; sourceTree = BUILT_PRODUCTS_DIR; };
622640DCAA11DD5022FC5DBF /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = "<group>"; };
DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindowController.swift; sourceTree = "<group>"; };
BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
A20000000000000000000012 /* ClickActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickActions.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -73,6 +83,8 @@
children = (
A20000000000000000000002 /* AudioDevice.swift */,
A20000000000000000000009 /* Headphones.swift */,
622640DCAA11DD5022FC5DBF /* AppInfo.swift */,
A20000000000000000000012 /* ClickActions.swift */,
);
path = Models;
sourceTree = "<group>";
Expand All @@ -83,6 +95,8 @@
A20000000000000000000003 /* AudioDeviceService.swift */,
A20000000000000000000004 /* PriorityManager.swift */,
A20000000000000000000008 /* LaunchAtLoginManager.swift */,
DE42A89AB153F7394DACCC1B /* PreferencesWindowController.swift */,
A20000000000000000000011 /* MenuBarController.swift */,
);
path = Services;
sourceTree = "<group>";
Expand All @@ -92,6 +106,7 @@
children = (
A20000000000000000000005 /* MenuBarView.swift */,
A20000000000000000000006 /* DeviceListView.swift */,
BA3538E8C4D0E18A321A5344 /* PreferencesView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -184,11 +199,16 @@
A10000000000000000000001 /* AudioPriorityBarApp.swift in Sources */,
A10000000000000000000002 /* AudioDevice.swift in Sources */,
A10000000000000000000009 /* Headphones.swift in Sources */,
4DD6D82E5CF6D899F9B4F327 /* AppInfo.swift in Sources */,
A10000000000000000000012 /* ClickActions.swift in Sources */,
A10000000000000000000003 /* AudioDeviceService.swift in Sources */,
A10000000000000000000004 /* PriorityManager.swift in Sources */,
5EACF86E108BAB90FAC19892 /* PreferencesWindowController.swift in Sources */,
A10000000000000000000005 /* MenuBarView.swift in Sources */,
A10000000000000000000006 /* DeviceListView.swift in Sources */,
ED800EC86195D082D84E8604 /* PreferencesView.swift in Sources */,
A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */,
A10000000000000000000011 /* MenuBarController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
136 changes: 120 additions & 16 deletions AudioPriorityBar/AudioPriorityBarApp.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import SwiftUI
import CoreAudio
import OSLog

@main
struct AudioPriorityBarApp: App {
@StateObject private var audioManager = AudioManager()
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
MenuBarExtra {
MenuBarView()
.environmentObject(audioManager)
} label: {
Image(systemName: "speaker.wave.2.fill")
// Return an empty scene - menu bar is handled by AppDelegate
Settings {
EmptyView()
}
.menuBarExtraStyle(.window)
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
var audioManager: AudioManager!
var menuBarController: MenuBarController?

@MainActor
func applicationDidFinishLaunching(_ notification: Notification) {
// Hide the app from the Dock
NSApp.setActivationPolicy(.accessory)

// Initialize audio manager
audioManager = AudioManager()

// Setup menu bar controller
menuBarController = MenuBarController(audioManager: audioManager)
}
}

Expand Down Expand Up @@ -93,6 +108,11 @@ class AudioManager: ObservableObject {
let priorityManager = PriorityManager()
private var connectedDeviceUIDs: Set<String> = []

private let logger = Logger(subsystem: "com.audioprioritybar", category: "AudioManager")
private var handleDeviceChangeCount = 0
private var applyInputCount = 0
private var applyOutputCount = 0

var menuBarIcon: String {
currentMode.icon
}
Expand Down Expand Up @@ -149,6 +169,25 @@ class AudioManager: ObservableObject {
func setVolume(_ newVolume: Float) {
volume = newVolume
deviceService.setOutputVolume(newVolume)

// Unmute the device if it's muted and volume is being changed
if let outputId = currentOutputId, isActiveOutputMuted {
deviceService.setDeviceMuted(outputId, type: .output, muted: false)
// Refresh mute status immediately
Task { @MainActor in
self.refreshMuteStatus()
}
}
}

func toggleOutputMute() {
guard let outputId = currentOutputId else { return }
let newMuteState = !isActiveOutputMuted
deviceService.setDeviceMuted(outputId, type: .output, muted: newMuteState)
// Refresh mute status immediately
Task { @MainActor in
self.refreshMuteStatus()
}
}

var activeOutputDevices: [AudioDevice] {
Expand Down Expand Up @@ -266,7 +305,15 @@ class AudioManager: ObservableObject {

func toggleMode() {
let newMode: OutputCategory = currentMode == .speaker ? .headphone : .speaker
setMode(newMode)

// Check if target mode has any connected devices
let targetDevices = newMode == .speaker ? speakerDevices : headphoneDevices
let hasConnectedDevices = targetDevices.contains { $0.isConnected }

// Only switch if target mode has connected devices
if hasConnectedDevices {
setMode(newMode)
}
}

func setCustomMode(_ enabled: Bool) {
Expand Down Expand Up @@ -350,6 +397,18 @@ class AudioManager: ObservableObject {
}
}

func setPreferredInput(_ inputDevice: AudioDevice, forOutput outputUID: String) {
priorityManager.setPreferredInput(inputDevice.uid, forOutput: outputUID)
}

func clearPreferredInput(forOutput outputUID: String) {
priorityManager.clearPreferredInput(forOutput: outputUID)
}

func getPreferredInputUID(forOutput outputUID: String) -> String? {
priorityManager.getPreferredInput(forOutput: outputUID)
}

func moveInputDevice(from source: IndexSet, to destination: Int) {
inputDevices.move(fromOffsets: source, toOffset: destination)
priorityManager.savePriorities(inputDevices, type: .input)
Expand Down Expand Up @@ -386,13 +445,44 @@ class AudioManager: ObservableObject {
}

private func applyInputDevice(_ device: AudioDevice) {
applyInputCount += 1
logger.debug("🎤 applyInputDevice #\(self.applyInputCount) - device: \(device.name) (id: \(device.id)), current: \(String(describing: self.currentInputId))")

guard currentInputId != device.id else {
logger.trace(" → Skipping: already current device")
return
}

deviceService.setDefaultDevice(device.id, type: .input)
currentInputId = device.id
logger.debug(" ✅ Input device set")
}

private func applyOutputDevice(_ device: AudioDevice) {
deviceService.setDefaultDevice(device.id, type: .output)
applyOutputCount += 1
logger.debug("🔊 applyOutputDevice #\(self.applyOutputCount) - device: \(device.name) (id: \(device.id)), current: \(String(describing: self.currentOutputId))")

guard currentOutputId != device.id else {
logger.trace(" → Skipping: already current device")
// Still check for preferred input even if output didn't change
if let preferredInputUID = priorityManager.getPreferredInput(forOutput: device.uid),
let preferredInput = inputDevices.first(where: { $0.uid == preferredInputUID && $0.isConnected && !priorityManager.isNeverUse($0) }) {
logger.debug(" → Applying preferred input for this output")
applyInputDevice(preferredInput)
}
return
}

deviceService.setDefaultDevice(device.id, type: .output, syncSystemOutput: priorityManager.syncSystemOutput)
currentOutputId = device.id
logger.debug(" ✅ Output device set")

// Check if there's a preferred input for this output device
if let preferredInputUID = priorityManager.getPreferredInput(forOutput: device.uid),
let preferredInput = inputDevices.first(where: { $0.uid == preferredInputUID && $0.isConnected && !priorityManager.isNeverUse($0) }) {
logger.debug(" → Applying preferred input for this output")
applyInputDevice(preferredInput)
}
}

private func applyHighestPriorityInput() {
Expand All @@ -410,29 +500,43 @@ class AudioManager: ObservableObject {
}

private func setupDeviceChangeListener() {
deviceService.onDevicesChanged = { [weak self] in
deviceService.onDevicesChanged = { [weak self] isDeviceListChange in
Task { @MainActor in
self?.handleDeviceChange()
self?.handleDeviceChange(isDeviceListChange: isDeviceListChange)
}
}
deviceService.startListening()
}

private func handleDeviceChange() {
private func handleDeviceChange(isDeviceListChange: Bool) {
handleDeviceChangeCount += 1
logger.debug("⚡️ handleDeviceChange #\(self.handleDeviceChangeCount) triggered - deviceListChange: \(isDeviceListChange)")

let oldConnectedUIDs = previousConnectedUIDs
refreshDevices()
refreshMuteStatus()

// Detect newly connected devices
let newlyConnectedUIDs = connectedDeviceUIDs.subtracting(oldConnectedUIDs)
previousConnectedUIDs = connectedDeviceUIDs

if !isCustomMode {

logger.debug(" → Newly connected devices: \(newlyConnectedUIDs.count), custom mode: \(self.isCustomMode)")

if !isCustomMode && isDeviceListChange {
// Only apply priorities when devices are added/removed, NOT when just switching
logger.debug(" → Device list changed - applying highest priority devices")
// Auto-switch mode only when a new headphone connects or all headphones disconnect
autoSwitchModeIfNeeded(newlyConnectedUIDs: newlyConnectedUIDs)
applyHighestPriorityInput()
applyHighestPriorityOutput()
} else if !isCustomMode {
logger.debug(" → Default device switched - NOT reapplying priorities (prevents loop)")
// Just update the UI, don't reapply priorities - this prevents the loop!
} else {
logger.debug(" → Custom mode enabled - not applying priorities")
}

logger.debug(" ✅ handleDeviceChange complete")
}

/// Automatically switches between headphone and speaker mode based on device connections.
Expand Down
24 changes: 24 additions & 0 deletions AudioPriorityBar/Models/AppInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

/// Utility struct to access app version and build information
struct AppInfo {
/// The short version string (e.g., "1.0.0")
static var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
}

/// The build number (e.g., "42")
static var build: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}

/// Combined version and build string (e.g., "Version 1.0.0 (42)")
static var versionString: String {
"Version \(version) (\(build))"
}

/// App name from bundle
static var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Audio Priority Bar"
}
}
34 changes: 34 additions & 0 deletions AudioPriorityBar/Models/ClickActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// Represents an action that can be triggered by clicking the menu bar icon
enum ClickAction: String, Codable, CaseIterable {
case toggle = "Toggle Mode"
case menu = "Show Menu"
case noAction = "No Action"

var displayName: String { self.rawValue }
}

/// Configuration for click actions on the menu bar icon
struct ClickActionsConfig: Codable {
var leftClick: ClickAction
var rightClick: ClickAction
var longLeftClick: ClickAction
var longRightClick: ClickAction

/// Default configuration: left and right click show menu, long presses do nothing
static var `default`: ClickActionsConfig {
ClickActionsConfig(
leftClick: .menu,
rightClick: .menu,
longLeftClick: .noAction,
longRightClick: .noAction
)
}

/// Validates that at least one action is set to show menu
var isValid: Bool {
leftClick == .menu || rightClick == .menu ||
longLeftClick == .menu || longRightClick == .menu
}
}
Loading