Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions boringNotch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -271,6 +272,7 @@
11F748812ECB07A400F841DB /* MusicControlButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicControlButton.swift; sourceTree = "<group>"; };
11F748832ECB27DC00F841DB /* MusicSlotConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicSlotConfigurationView.swift; sourceTree = "<group>"; };
14288DD62C6E015000B9F80C /* AudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputRouteResolver.swift; sourceTree = "<group>"; };
14288DE72C6E01C800B9F80C /* ProgressIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = "<group>"; };
14288E0B2C6F8EC000B9F80C /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = "<group>"; };
1443E7F22C609DCE0027C1FC /* matters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = matters.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -552,6 +554,7 @@
14288DD62C6E015000B9F80C /* AudioPlayer.swift */,
5955950C2E900ED800C66711 /* ApplicationRelauncher.swift */,
14288E0B2C6F8EC000B9F80C /* AppIcons.swift */,
AFAD1670A870402D88BFFE47 /* AudioOutputRouteResolver.swift */,
);
path = helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
17 changes: 1 addition & 16 deletions boringNotch/components/Notch/NotchHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
22 changes: 6 additions & 16 deletions boringNotch/components/OSD/Views/OSDIconView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
}
}
9 changes: 0 additions & 9 deletions boringNotch/components/OSD/Views/OpenNotchOSD.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
207 changes: 207 additions & 0 deletions boringNotch/helpers/AudioOutputRouteResolver.swift
Original file line number Diff line number Diff line change
@@ -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<AudioObjectID>.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
Copy link
Member

@Alexander5015 Alexander5015 Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some improvements can be made here, but I have to look into this more. This is more of a note to me than a blocker for merging.

case kAudioDeviceTransportTypeUSB:
return .wiredHeadphones
case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort:
return isHeadphonesLike ? .wiredHeadphones : .externalSpeaker
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of this check? My understanding is this will always be an externalSpeaker if its a display, right?

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<UInt32>.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<CFString>.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"
}
}
}