Skip to content

Commit 4b41655

Browse files
tobiclaude
andcommitted
v1.1: Enhanced menu bar, event-based updates, UX improvements
Menu Bar Enhancements: - Volume indicator using SF Symbol variable values (speaker.wave.3.fill) - Flashing mic icon when microphone is muted (alternates mic.fill/mic.slash.fill) - Show headphone icon in headphone mode alongside volume indicator - Custom mode hand icon displayed before volume Event-Based Audio Monitoring: - Replace polling timer with CoreAudio property listeners - Listen for mute/volume changes on all devices - Listen for default device changes - More responsive and efficient updates UX Improvements: - Device rows show "Active" label instead of priority number for selected device - Blue border highlight on active device row - Device rows no longer grayed out in normal modes (only disconnected/hidden) - Clicking devices only selects in custom mode (reordering still works via drag/arrows) - Removed "Active" badge from section headers (now shown on device row) Bug Fixes: - Fixed mute detection not skipping to next device (now stays on muted device as intended) - Simplified device selection logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bb11706 commit 4b41655

4 files changed

Lines changed: 242 additions & 94 deletions

File tree

AudioPriorityBar/AudioPriorityBarApp.swift

Lines changed: 55 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -10,37 +10,43 @@ struct AudioPriorityBarApp: App {
1010
MenuBarView()
1111
.environmentObject(audioManager)
1212
} label: {
13-
MenuBarLabel()
14-
.environmentObject(audioManager)
13+
MenuBarLabel(
14+
volume: audioManager.volume,
15+
isOutputMuted: audioManager.isActiveOutputMuted,
16+
isInputMuted: audioManager.isActiveInputMuted,
17+
isCustomMode: audioManager.isCustomMode,
18+
mode: audioManager.currentMode,
19+
micFlash: audioManager.micFlashState
20+
)
1521
}
1622
.menuBarExtraStyle(.window)
1723
}
1824
}
1925

2026
struct MenuBarLabel: View {
21-
@EnvironmentObject var audioManager: AudioManager
22-
23-
var iconName: String {
24-
if audioManager.isActiveOutputMuted {
25-
return "speaker.slash.fill"
26-
} else if audioManager.isCustomMode {
27-
return "hand.raised.fill"
28-
} else if audioManager.currentMode == .headphone {
29-
return "headphones"
30-
} else {
31-
return "speaker.fill"
32-
}
33-
}
27+
let volume: Float
28+
let isOutputMuted: Bool
29+
let isInputMuted: Bool
30+
let isCustomMode: Bool
31+
let mode: OutputCategory
32+
let micFlash: Bool
3433

3534
var body: some View {
36-
HStack(spacing: 3) {
37-
Image(systemName: iconName)
38-
.font(.system(size: 12))
39-
.foregroundColor(audioManager.isActiveOutputMuted ? .red : nil)
40-
41-
// Volume meter bars
42-
VolumeMeterView(volume: audioManager.volume, isMuted: audioManager.isActiveOutputMuted)
43-
.frame(width: 16, height: 11)
35+
HStack(spacing: 2) {
36+
if isInputMuted {
37+
Image(systemName: micFlash ? "mic.fill" : "mic.slash.fill")
38+
}
39+
if isCustomMode {
40+
Image(systemName: "hand.raised.fill")
41+
} else if mode == .headphone {
42+
Image(systemName: "headphones")
43+
}
44+
// Speaker with volume always last
45+
if isOutputMuted {
46+
Image(systemName: "speaker.slash.fill")
47+
} else {
48+
Image(systemName: "speaker.wave.3.fill", variableValue: Double(volume))
49+
}
4450
}
4551
}
4652
}
@@ -92,11 +98,12 @@ class AudioManager: ObservableObject {
9298
@Published var mutedDeviceIds: Set<AudioObjectID> = []
9399
@Published var isActiveOutputMuted: Bool = false
94100
@Published var isActiveInputMuted: Bool = false
101+
@Published var micFlashState: Bool = false
95102

96103
private let deviceService = AudioDeviceService()
104+
private var micFlashTimer: Timer?
97105
let priorityManager = PriorityManager()
98106
private var connectedDeviceUIDs: Set<String> = []
99-
private var muteCheckTimer: Timer?
100107

101108
var menuBarIcon: String {
102109
currentMode.icon
@@ -141,6 +148,19 @@ class AudioManager: ObservableObject {
141148
} else {
142149
isActiveInputMuted = false
143150
}
151+
152+
// Start/stop mic flash timer based on mute state
153+
if isActiveInputMuted && micFlashTimer == nil {
154+
micFlashTimer = Timer.scheduledTimer(withTimeInterval: 0.7, repeats: true) { [weak self] _ in
155+
Task { @MainActor in
156+
self?.micFlashState.toggle()
157+
}
158+
}
159+
} else if !isActiveInputMuted && micFlashTimer != nil {
160+
micFlashTimer?.invalidate()
161+
micFlashTimer = nil
162+
micFlashState = false
163+
}
144164
}
145165

146166
func isDeviceMuted(_ device: AudioDevice) -> Bool {
@@ -166,23 +186,27 @@ class AudioManager: ObservableObject {
166186
refreshVolume()
167187
refreshMuteStatus()
168188
setupDeviceChangeListener()
169-
startMuteCheckTimer()
189+
setupMuteVolumeListener()
170190
// Apply priority on startup (unless in custom mode)
171191
if !isCustomMode {
172192
applyHighestPriorityInput()
173193
applyHighestPriorityOutput()
174194
}
175195
}
176196

177-
private func startMuteCheckTimer() {
178-
muteCheckTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
197+
private func setupMuteVolumeListener() {
198+
deviceService.onMuteOrVolumeChanged = { [weak self] in
179199
Task { @MainActor in
180-
self?.refreshMuteStatus()
181-
self?.refreshVolume()
200+
self?.handleMuteOrVolumeChange()
182201
}
183202
}
184203
}
185204

205+
private func handleMuteOrVolumeChange() {
206+
refreshMuteStatus()
207+
refreshVolume()
208+
}
209+
186210
func refreshDevices() {
187211
let allConnectedDevices = deviceService.getDevices()
188212

@@ -366,33 +390,11 @@ class AudioManager: ObservableObject {
366390
}
367391

368392
func setInputDevice(_ device: AudioDevice) {
369-
if isCustomMode {
370-
// Custom mode: just select the device directly
371-
applyInputDevice(device)
372-
} else {
373-
// Normal mode: move device to top of priority list
374-
moveDeviceToTop(device, in: &inputDevices)
375-
priorityManager.savePriorities(inputDevices, type: .input)
376-
applyHighestPriorityInput()
377-
}
393+
applyInputDevice(device)
378394
}
379395

380396
func setOutputDevice(_ device: AudioDevice) {
381-
if isCustomMode {
382-
// Custom mode: just select the device directly
383-
applyOutputDevice(device)
384-
} else {
385-
// Normal mode: move device to top of priority list for current category
386-
let category = priorityManager.getCategory(for: device)
387-
if category == .speaker {
388-
moveDeviceToTop(device, in: &speakerDevices)
389-
priorityManager.savePriorities(speakerDevices, category: .speaker)
390-
} else {
391-
moveDeviceToTop(device, in: &headphoneDevices)
392-
priorityManager.savePriorities(headphoneDevices, category: .headphone)
393-
}
394-
applyHighestPriorityOutput()
395-
}
397+
applyOutputDevice(device)
396398
}
397399

398400
private func applyInputDevice(_ device: AudioDevice) {
@@ -405,38 +407,14 @@ class AudioManager: ObservableObject {
405407
currentOutputId = device.id
406408
}
407409

408-
private func moveDeviceToTop(_ device: AudioDevice, in devices: inout [AudioDevice]) {
409-
if let index = devices.firstIndex(where: { $0.uid == device.uid }) {
410-
let removed = devices.remove(at: index)
411-
devices.insert(removed, at: 0)
412-
}
413-
}
414-
415410
private func applyHighestPriorityInput() {
416-
// Find highest priority non-muted input device
417-
for device in inputDevices where device.isConnected {
418-
if !deviceService.isDeviceMuted(device.id, type: .input) {
419-
applyInputDevice(device)
420-
return
421-
}
422-
}
423-
// Fallback: if all are muted, just use first one
424411
if let first = inputDevices.first(where: { $0.isConnected }) {
425412
applyInputDevice(first)
426413
}
427414
}
428415

429416
private func applyHighestPriorityOutput() {
430417
let devices = activeOutputDevices
431-
// Find highest priority non-muted output device
432-
for device in devices where device.isConnected {
433-
if !deviceService.isDeviceMuted(device.id, type: .output) {
434-
applyOutputDevice(device)
435-
refreshMuteStatus()
436-
return
437-
}
438-
}
439-
// Fallback: if all are muted, just use first one
440418
if let first = devices.first(where: { $0.isConnected }) {
441419
applyOutputDevice(first)
442420
}

0 commit comments

Comments
 (0)