From 5326abe150e1326d78b9b031760e56bb01c49cd8 Mon Sep 17 00:00:00 2001 From: Colin Date: Thu, 5 Mar 2026 13:00:05 -0500 Subject: [PATCH 1/2] feat: add dynamic audio output icons based on connected device --- boringNotch.xcodeproj/project.pbxproj | 4 + .../components/Notch/NotchHomeView.swift | 17 +- .../components/OSD/Views/OSDIconView.swift | 22 +- .../components/OSD/Views/OpenNotchOSD.swift | 9 - .../helpers/AudioOutputRouteResolver.swift | 207 ++++++++++++++++++ 5 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 boringNotch/helpers/AudioOutputRouteResolver.swift diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 0e90a4fd3..4c6589bac 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748812ECB07A400F841DB /* MusicControlButton.swift */; }; 11F748842ECB27DC00F841DB /* MusicSlotConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */; }; 14288DDC2C6E015000B9F80C /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DD62C6E015000B9F80C /* AudioPlayer.swift */; }; + 201C3E071A104384B8629328 /* AudioOutputRouteResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */; }; 14288DE82C6E01C800B9F80C /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */; }; 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */; }; 1443E7F32C609DCE0027C1FC /* matters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1443E7F22C609DCE0027C1FC /* matters.swift */; }; @@ -271,6 +272,7 @@ 11F748812ECB07A400F841DB /* MusicControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControlButton.swift; sourceTree = ""; }; 11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSlotConfigurationView.swift; sourceTree = ""; }; 14288DD62C6E015000B9F80C /* AudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = ""; }; 14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; 1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = ""; }; @@ -552,6 +554,7 @@ 14288DD62C6E015000B9F80C /* AudioPlayer.swift */, 5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */, 14288E0B2C6F8EC000B9F80C /* AppIcons.swift */, + AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */, ); path = helpers; sourceTree = ""; @@ -1101,6 +1104,7 @@ 11D58EA22E760AE100FA8377 /* ImageService.swift in Sources */, 11F748822ECB07A400F841DB /* MusicControlButton.swift in Sources */, 14288DDC2C6E015000B9F80C /* AudioPlayer.swift in Sources */, + 201C3E071A104384B8629328 /* AudioOutputRouteResolver.swift in Sources */, 149E0B972C737D00006418B1 /* WebcamManager.swift in Sources */, 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */, B1C448982C972CC4001F0858 /* ListItemPopover.swift in Sources */, diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 7bbfaec8b..b3f9f5026 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -335,7 +335,7 @@ struct VolumeControlView: View { } } }) { - Image(systemName: volumeIcon) + Image(systemName: musicManager.volumeControlSupported ? AudioOutputRouteResolver.shared.volumeSymbol(for: CGFloat(volumeSliderValue)) : "speaker.slash") .font(.system(size: 14, weight: .medium)) .foregroundColor(musicManager.volumeControlSupported ? .white : .gray) } @@ -390,21 +390,6 @@ struct VolumeControlView: View { // volumeUpdateTask?.cancel() // No longer needed } } - - - private var volumeIcon: String { - if !musicManager.volumeControlSupported { - return "speaker.slash" - } else if volumeSliderValue == 0 { - return "speaker.slash.fill" - } else if volumeSliderValue < 0.33 { - return "speaker.1.fill" - } else if volumeSliderValue < 0.66 { - return "speaker.2.fill" - } else { - return "speaker.3.fill" - } - } } // MARK: - Main View diff --git a/boringNotch/components/OSD/Views/OSDIconView.swift b/boringNotch/components/OSD/Views/OSDIconView.swift index deabb52bc..30dac84dc 100644 --- a/boringNotch/components/OSD/Views/OSDIconView.swift +++ b/boringNotch/components/OSD/Views/OSDIconView.swift @@ -18,17 +18,11 @@ struct OSDIconView: View { var body: some View { switch (eventType) { case .volume: - if icon.isEmpty { - SpeakerSymbol(value) - .contentTransition(.interpolate) - .frame(width: 20, height: 15, alignment: .leading) - } else { - Image(systemName: icon) - .contentTransition(.interpolate) - .opacity(value.isZero ? 0.6 : 1) - .scaleEffect(value.isZero ? 0.85 : 1) - .frame(width: 20, height: 15, alignment: .leading) - } + Image(systemName: icon.isEmpty ? AudioOutputRouteResolver.shared.volumeSymbol(for: value) : icon) + .contentTransition(.interpolate) + .opacity(value.isZero ? 0.6 : 1) + .scaleEffect(value.isZero ? 0.85 : 1) + .frame(width: 20, height: 15, alignment: .leading) case .brightness: let symbol = icon.isEmpty ? BrightnessSymbolString(value) : icon Image(systemName: symbol) @@ -51,11 +45,7 @@ struct OSDIconView: View { } } - private func SpeakerSymbol(_ value: CGFloat) -> Image { - let iconString = value == 0 ? "speaker.slash.fill" : "speaker.wave.3.fill" - return Image(systemName: iconString, variableValue: value) - } - private func BrightnessSymbolString(_ value: CGFloat) -> String { +private func BrightnessSymbolString(_ value: CGFloat) -> String { return value < 0.3 ? "sun.min.fill" : "sun.max.fill" } } diff --git a/boringNotch/components/OSD/Views/OpenNotchOSD.swift b/boringNotch/components/OSD/Views/OpenNotchOSD.swift index 21f4755d8..18d74dc4e 100644 --- a/boringNotch/components/OSD/Views/OpenNotchOSD.swift +++ b/boringNotch/components/OSD/Views/OpenNotchOSD.swift @@ -55,15 +55,6 @@ struct OpenNotchOSD: View { ) } - func SpeakerSymbol(_ value: CGFloat) -> String { - switch(value) { - case 0: return "speaker.slash" - case 0...0.33: return "speaker.wave.1" - case 0.33...0.66: return "speaker.wave.2" - default: return "speaker.wave.3" - } - } - func updateSystemValue(_ newVal: CGFloat) { switch type { case .volume: diff --git a/boringNotch/helpers/AudioOutputRouteResolver.swift b/boringNotch/helpers/AudioOutputRouteResolver.swift new file mode 100644 index 000000000..26550f747 --- /dev/null +++ b/boringNotch/helpers/AudioOutputRouteResolver.swift @@ -0,0 +1,207 @@ +// +// AudioOutputRouteResolver.swift +// boringNotch +// +// Shared output-route to icon mapping used by all OSD/HUD surfaces. +// + +import CoreAudio +import CoreGraphics +import Foundation + +enum AudioOutputRouteKind: Equatable { + case builtInSpeaker + case wiredHeadphones + case airPods + case airPodsPro + case airPodsMax + case bluetoothHeadphones + case externalSpeaker + case unknown +} + +final class AudioOutputRouteResolver { + static let shared = AudioOutputRouteResolver() + + private let stateQueue = DispatchQueue(label: "AudioOutputRouteResolver.state") + private var cachedRouteKind: AudioOutputRouteKind = .unknown + + func volumeSymbol(for value: CGFloat) -> String { + let clampedValue = max(0, min(1, value)) + let routeKind = stateQueue.sync { cachedRouteKind } + + switch routeKind { + case .airPods: + return "airpods" + case .airPodsPro: + return "airpodspro" + case .airPodsMax: + return "airpodsmax" + case .wiredHeadphones, .bluetoothHeadphones: + return "headphones" + case .builtInSpeaker, .externalSpeaker, .unknown: + return speakerSymbol(for: clampedValue) + } + } + + private init() { + refreshCachedRouteKind() + setupAudioRouteListener() + } + + private func setupAudioRouteListener() { + var defaultDeviceAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), + &defaultDeviceAddress, + nil + ) { [weak self] _, _ in + self?.refreshCachedRouteKind() + } + } + + private func refreshCachedRouteKind() { + let currentRoute = currentRouteKind() + stateQueue.sync { + self.cachedRouteKind = currentRoute + } + } + + private func currentRouteKind() -> AudioOutputRouteKind { + let deviceID = systemOutputDeviceID() + guard deviceID != kAudioObjectUnknown else { return .unknown } + + let deviceName = readStringProperty(deviceID: deviceID, selector: kAudioObjectPropertyName) + let manufacturer = readStringProperty( + deviceID: deviceID, + selector: kAudioObjectPropertyManufacturer + ) + let transportType = readTransportType(deviceID: deviceID) + + return classifyOutputRoute( + deviceName: deviceName, + manufacturer: manufacturer, + transportType: transportType + ) + } + + private func systemOutputDeviceID() -> AudioObjectID { + var defaultDeviceID = kAudioObjectUnknown + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &dataSize, + &defaultDeviceID + ) + return status == noErr ? defaultDeviceID : kAudioObjectUnknown + } + + private func classifyOutputRoute( + deviceName: String, + manufacturer _: String, + transportType: UInt32? + ) -> AudioOutputRouteKind { + let normalizedName = deviceName.lowercased() + + if normalizedName.contains("airpods max") { + return .airPodsMax + } + if normalizedName.contains("airpods pro") { + return .airPodsPro + } + if normalizedName.contains("airpods") { + return .airPods + } + + let isHeadphonesLike = normalizedName.contains("headphone") + || normalizedName.contains("headset") + || normalizedName.contains("earbud") + || normalizedName.contains("earphone") + || normalizedName.contains("pods") + + switch transportType { + case kAudioDeviceTransportTypeBuiltIn: + return isHeadphonesLike ? .wiredHeadphones : .builtInSpeaker + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + return .bluetoothHeadphones + case kAudioDeviceTransportTypeUSB: + return .wiredHeadphones + case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: + return isHeadphonesLike ? .wiredHeadphones : .externalSpeaker + default: + if isHeadphonesLike { + return .wiredHeadphones + } + if normalizedName.contains("speaker") || normalizedName.contains("display") { + return .externalSpeaker + } + return .unknown + } + } + + private func readTransportType(deviceID: AudioObjectID) -> UInt32? { + var transportType: UInt32 = 0 + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + deviceID, + &address, + 0, + nil, + &dataSize, + &transportType + ) + + return status == noErr ? transportType : nil + } + + private func readStringProperty( + deviceID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> String { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var value: CFString = "" as CFString + var propertySize: UInt32 = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &propertySize, &value) == noErr + else { + return "" + } + + return value as String + } + + private func speakerSymbol(for value: CGFloat) -> String { + switch value { + case 0: + return "speaker.slash.fill" + case 0...0.33: + return "speaker.wave.1" + case 0.33...0.66: + return "speaker.wave.2" + default: + return "speaker.wave.3" + } + } +} From 503910488ec94995bb8bf93aae514a50df722463 Mon Sep 17 00:00:00 2001 From: Alexander <33609792+Alexander5015@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:01:08 -0700 Subject: [PATCH 2/2] Update boringNotch/helpers/AudioOutputRouteResolver.swift --- boringNotch/helpers/AudioOutputRouteResolver.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/boringNotch/helpers/AudioOutputRouteResolver.swift b/boringNotch/helpers/AudioOutputRouteResolver.swift index 26550f747..d18d4b209 100644 --- a/boringNotch/helpers/AudioOutputRouteResolver.swift +++ b/boringNotch/helpers/AudioOutputRouteResolver.swift @@ -197,11 +197,11 @@ final class AudioOutputRouteResolver { case 0: return "speaker.slash.fill" case 0...0.33: - return "speaker.wave.1" + return "speaker.wave.1.fill" case 0.33...0.66: - return "speaker.wave.2" + return "speaker.wave.2.fill" default: - return "speaker.wave.3" + return "speaker.wave.3.fill" } } }