Systemlautstärke und Gerätewahl im Mixer
Ausgabe und Eingabe stehen jetzt über den Programmen — mit Lautstärke, Stummschalter und der Gerätewahl direkt am Namen. Ein eigenes Auswahlfeld daneben wäre eine zweite Stelle für dieselbe Sache. Sie stehen **außerhalb** des Mixer-Schalters, und das ist der Punkt: hier wird kein Ton abgegriffen, sondern es werden die Regler des Systems bedient. Das braucht keinen Tap, kein Aggregate-Gerät und keine Berechtigung — wer nur die Lautstärke sucht, soll dafür nichts einschalten müssen. Zwei Dinge, die beim Bauen nicht offensichtlich waren: Viele Geräte führen keinen Regler für Element 0, sondern nur je Kanal. Fragt man bloß den Hauptregler, hält man ein regelbares Gerät für unregelbar. Also erst der Hauptregler, dann die Kanäle — und beim Setzen beide Kanäle, sonst wandert das Stereobild. Manche Geräte bringen gar keinen Regler mit, HDMI etwa. Dort steht „hier nicht regelbar" statt eines Reglers, der nichts tut. Warntöne folgen dem Ausgabegerät mit. Zwei getrennte Wahlen sind die Quelle für „warum kommt der Systemton aus dem Notebook". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,10 +17,14 @@ final class MixerMenuBarModule: MenuBarModule {
|
|||||||
var shortLabel: String { String(localized: "mixer.menubar.short") }
|
var shortLabel: String { String(localized: "mixer.menubar.short") }
|
||||||
|
|
||||||
private let mixer: AudioMixer
|
private let mixer: AudioMixer
|
||||||
|
private let system: SystemAudio
|
||||||
private var view: MixerStatusView?
|
private var view: MixerStatusView?
|
||||||
private var isActive = false
|
private var isActive = false
|
||||||
|
|
||||||
init(mixer: AudioMixer) { self.mixer = mixer }
|
init(mixer: AudioMixer, system: SystemAudio) {
|
||||||
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
let view = MixerStatusView()
|
let view = MixerStatusView()
|
||||||
@@ -32,7 +36,7 @@ final class MixerMenuBarModule: MenuBarModule {
|
|||||||
func makePopoverView() -> AnyView {
|
func makePopoverView() -> AnyView {
|
||||||
AnyView(
|
AnyView(
|
||||||
ScrollView {
|
ScrollView {
|
||||||
MixerView(mixer: mixer)
|
MixerView(mixer: mixer, system: system)
|
||||||
.padding(14)
|
.padding(14)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.automatic)
|
.scrollIndicators(.automatic)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
private let fanControl = FanControl()
|
private let fanControl = FanControl()
|
||||||
private let audioMixer = AudioMixer()
|
private let audioMixer = AudioMixer()
|
||||||
|
private let systemAudio = SystemAudio()
|
||||||
private let launchAtLogin = LaunchAtLogin()
|
private let launchAtLogin = LaunchAtLogin()
|
||||||
private let onboarding = OnboardingWindowController()
|
private let onboarding = OnboardingWindowController()
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
|
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
|
||||||
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
|
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
|
||||||
// über ein Fenster zu weit.
|
// über ein Fenster zu weit.
|
||||||
menuBar.register(MixerMenuBarModule(mixer: audioMixer),
|
menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio),
|
||||||
settings: model.menuBarSettings(for: "audio"))
|
settings: model.menuBarSettings(for: "audio"))
|
||||||
menuBar.register(FanMenuBarModule(control: fanControl),
|
menuBar.register(FanMenuBarModule(control: fanControl),
|
||||||
settings: model.menuBarSettings(for: "fans"))
|
settings: model.menuBarSettings(for: "fans"))
|
||||||
@@ -218,7 +219,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self.networkModel = networkModel
|
self.networkModel = networkModel
|
||||||
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
|
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
|
||||||
|
|
||||||
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer))
|
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer, system: systemAudio))
|
||||||
|
|
||||||
WidgetRegistry.shared.register(FanWidget(control: fanControl))
|
WidgetRegistry.shared.register(FanWidget(control: fanControl))
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +1,221 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage": "en",
|
||||||
"strings" : {
|
"strings": {
|
||||||
"" : {
|
"": {},
|
||||||
|
"%lld %%": {
|
||||||
},
|
"comment": "A row of controls for adjusting the volume and muting a process.",
|
||||||
"–" : {
|
"isCommentAutoGenerated": true,
|
||||||
"comment" : "A placeholder for the volume of a process.",
|
"localizations": {
|
||||||
"isCommentAutoGenerated" : true
|
"de": {
|
||||||
},
|
"stringUnit": {
|
||||||
"%lld %%" : {
|
"state": "translated",
|
||||||
"comment" : "A row of controls for adjusting the volume and muting a process.",
|
"value": "%lld %%"
|
||||||
"isCommentAutoGenerated" : true,
|
|
||||||
"localizations" : {
|
|
||||||
"de" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "%lld %%"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "%lld %%"
|
"value": "%lld %%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.name" : {
|
"mixer.input": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Audio-Mixer"
|
"value": "Eingabe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Audio mixer"
|
"value": "Input"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.noProcesses" : {
|
"mixer.name": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Kein Programm gibt gerade Ton aus"
|
"value": "Audio-Mixer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "No app is playing audio"
|
"value": "Audio mixer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.off" : {
|
"mixer.noProcesses": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Mixer ausschalten"
|
"value": "Kein Programm gibt gerade Ton aus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Turn mixer off"
|
"value": "No app is playing audio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.off.explain" : {
|
"mixer.notAdjustable": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Lautstärke einzelner Programme regeln, auch über 100 %."
|
"value": "Hier nicht regelbar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Set the volume of individual apps, including above 100 %."
|
"value": "Not adjustable here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.off.warning" : {
|
"mixer.off": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt."
|
"value": "Mixer ausschalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately."
|
"value": "Turn mixer off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.on" : {
|
"mixer.off.explain": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Mixer einschalten"
|
"value": "Lautstärke einzelner Programme regeln, auch über 100 %."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Turn mixer on"
|
"value": "Set the volume of individual apps, including above 100 %."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mixer.release" : {
|
"mixer.off.warning": {
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"de" : {
|
"de": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Nicht mehr regeln"
|
"value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Stop controlling"
|
"value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately."
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"mixer.on": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Mixer einschalten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn mixer on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.output": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ausgabe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Output"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.release": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nicht mehr regeln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stop controlling"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.section.apps": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Programme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Programs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mixer.section.system": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "System"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "System"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"–": {
|
||||||
|
"comment": "A placeholder for the volume of a process.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.1"
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
import AppKit
|
import AppKit
|
||||||
import OnyxDesign
|
import OnyxDesign
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
|
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
|
||||||
///
|
///
|
||||||
@@ -14,16 +15,27 @@ import OnyxWidgetKit
|
|||||||
/// gewöhnliche Lautstärke nicht.
|
/// gewöhnliche Lautstärke nicht.
|
||||||
public struct MixerView: View {
|
public struct MixerView: View {
|
||||||
let mixer: AudioMixer
|
let mixer: AudioMixer
|
||||||
|
let system: SystemAudio
|
||||||
/// Im Panel ist es eng, im Popover nicht.
|
/// Im Panel ist es eng, im Popover nicht.
|
||||||
let compact: Bool
|
let compact: Bool
|
||||||
|
|
||||||
public init(mixer: AudioMixer, compact: Bool = false) {
|
public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) {
|
||||||
self.mixer = mixer
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
self.compact = compact
|
self.compact = compact
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(alignment: .leading, spacing: compact ? 10 : 14) {
|
VStack(alignment: .leading, spacing: compact ? 10 : 12) {
|
||||||
|
// Die Systemregler stehen **vor** den Programmen und außerhalb des
|
||||||
|
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
|
||||||
|
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
|
||||||
|
// nichts einschalten müssen.
|
||||||
|
SectionTitle("mixer.section.system", compact: compact)
|
||||||
|
SystemSection(system: system, compact: compact)
|
||||||
|
|
||||||
|
SectionTitle("mixer.section.apps", compact: compact)
|
||||||
|
|
||||||
if mixer.isEnabled {
|
if mixer.isEnabled {
|
||||||
if mixer.processes.isEmpty {
|
if mixer.processes.isEmpty {
|
||||||
Text("mixer.noProcesses", bundle: .module)
|
Text("mixer.noProcesses", bundle: .module)
|
||||||
@@ -314,15 +326,216 @@ public struct AudioMixerWidget: OnyxWidget {
|
|||||||
public let symbolName = "slider.horizontal.3"
|
public let symbolName = "slider.horizontal.3"
|
||||||
|
|
||||||
private let mixer: AudioMixer
|
private let mixer: AudioMixer
|
||||||
|
private let system: SystemAudio
|
||||||
|
|
||||||
public init(mixer: AudioMixer) { self.mixer = mixer }
|
public init(mixer: AudioMixer, system: SystemAudio) {
|
||||||
|
self.mixer = mixer
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
public func makeView() -> AnyView {
|
public func makeView() -> AnyView {
|
||||||
AnyView(
|
AnyView(
|
||||||
ScrollView {
|
ScrollView {
|
||||||
MixerView(mixer: mixer, compact: true)
|
MixerView(mixer: mixer, system: system, compact: true)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Systemzeilen
|
||||||
|
|
||||||
|
private struct SectionTitle: View {
|
||||||
|
let key: LocalizedStringKey
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
init(_ key: LocalizedStringKey, compact: Bool) {
|
||||||
|
self.key = key
|
||||||
|
self.compact = compact
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(key, bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.padding(.top, compact ? 0 : 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ausgabe, Eingabe und Gerätewahl.
|
||||||
|
///
|
||||||
|
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
|
||||||
|
/// Regler, Wert. Zwei verschiedene Regler in einem Fenster wären eine
|
||||||
|
/// Behauptung, dass es zwei verschiedene Dinge sind — es ist aber beides
|
||||||
|
/// Lautstärke.
|
||||||
|
private struct SystemSection: View {
|
||||||
|
let system: SystemAudio
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SystemRow(
|
||||||
|
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: outputName,
|
||||||
|
devices: system.outputDevices,
|
||||||
|
selected: system.outputDeviceID,
|
||||||
|
volume: system.outputVolume,
|
||||||
|
muted: system.outputMuted,
|
||||||
|
adjustable: system.outputIsAdjustable,
|
||||||
|
compact: compact,
|
||||||
|
onVolume: { system.setOutputVolume($0) },
|
||||||
|
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||||||
|
onSelect: { system.selectOutput($0) })
|
||||||
|
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
|
||||||
|
SystemRow(
|
||||||
|
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||||||
|
title: inputName,
|
||||||
|
devices: system.inputDevices,
|
||||||
|
selected: system.inputDeviceID,
|
||||||
|
volume: system.inputVolume,
|
||||||
|
muted: system.inputMuted,
|
||||||
|
adjustable: system.inputIsAdjustable,
|
||||||
|
compact: compact,
|
||||||
|
onVolume: { system.setInputVolume($0) },
|
||||||
|
onMute: { system.setInputMuted(!system.inputMuted) },
|
||||||
|
onSelect: { system.selectInput($0) })
|
||||||
|
}
|
||||||
|
.background(Onyx.Color.elevated.opacity(0.55),
|
||||||
|
in: .rect(cornerRadius: 10, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var outputName: String {
|
||||||
|
system.outputDevices.first { $0.id == system.outputDeviceID }?.name
|
||||||
|
?? String(localized: "mixer.output", bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputName: String {
|
||||||
|
system.inputDevices.first { $0.id == system.inputDeviceID }?.name
|
||||||
|
?? String(localized: "mixer.input", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SystemRow: View {
|
||||||
|
let symbol: String
|
||||||
|
let title: String
|
||||||
|
let devices: [AudioDevice]
|
||||||
|
let selected: AudioObjectID
|
||||||
|
let volume: Double
|
||||||
|
let muted: Bool
|
||||||
|
let adjustable: Bool
|
||||||
|
let compact: Bool
|
||||||
|
let onVolume: (Double) -> Void
|
||||||
|
let onMute: () -> Void
|
||||||
|
let onSelect: (AudioDevice) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: compact ? 5 : 6) {
|
||||||
|
// Der Gerätename **ist** die Auswahl: ein eigenes Auswahlfeld
|
||||||
|
// daneben wäre eine zweite Stelle für dieselbe Sache.
|
||||||
|
Menu {
|
||||||
|
ForEach(devices) { device in
|
||||||
|
Button { onSelect(device) } label: {
|
||||||
|
if device.id == selected {
|
||||||
|
Label(device.name, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(device.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 7))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.menuIndicator(.hidden)
|
||||||
|
.fixedSize()
|
||||||
|
|
||||||
|
HStack(spacing: 9) {
|
||||||
|
Button(action: onMute) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(width: 15, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||||||
|
|
||||||
|
if adjustable {
|
||||||
|
SystemVolumeBar(volume: volume, isActive: !muted, onChange: onVolume)
|
||||||
|
Text("\(Int(volume * 100)) %")
|
||||||
|
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
} else {
|
||||||
|
// Manche Geräte bringen keinen eigenen Regler mit — HDMI,
|
||||||
|
// viele USB-Interfaces. Ein Regler, der nichts tut, wäre
|
||||||
|
// eine Pseudobedienung.
|
||||||
|
Text("mixer.notAdjustable", bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, compact ? 7 : 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
|
||||||
|
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
|
||||||
|
private struct SystemVolumeBar: View {
|
||||||
|
let volume: Double
|
||||||
|
let isActive: Bool
|
||||||
|
let onChange: (Double) -> Void
|
||||||
|
|
||||||
|
@State private var isDragging = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let knob: CGFloat = isDragging ? 13 : 11
|
||||||
|
let travel = max(geometry.size.width - knob, 1)
|
||||||
|
let fraction = min(max(volume, 0), 1)
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Onyx.Color.surface).frame(height: 6)
|
||||||
|
Capsule()
|
||||||
|
.fill(Onyx.Color.accent)
|
||||||
|
.frame(width: travel * fraction + knob / 2, height: 6)
|
||||||
|
.opacity(isActive ? 1 : 0.3)
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: knob, height: knob)
|
||||||
|
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||||
|
.offset(x: travel * fraction)
|
||||||
|
.opacity(isActive ? 1 : 0.55)
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
isDragging = true
|
||||||
|
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
|
||||||
|
}
|
||||||
|
.onEnded { _ in isDragging = false })
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
.animation(Onyx.Motion.value, value: isDragging)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
313
Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
Normal file
313
Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreAudio
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
|
||||||
|
|
||||||
|
/// Ein Ein- oder Ausgabegerät.
|
||||||
|
public struct AudioDevice: Identifiable, Equatable, Sendable {
|
||||||
|
public let id: AudioObjectID
|
||||||
|
public let name: String
|
||||||
|
public let isOutput: Bool
|
||||||
|
public let isInput: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Systemlautstärken und die Gerätewahl.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Mischpult der einzelnen Programme, weil es technisch etwas
|
||||||
|
/// ganz anderes ist: hier wird kein Ton abgegriffen, hier werden die Regler
|
||||||
|
/// des Systems bedient. Das geht ohne Tap, ohne Aggregate-Gerät und ohne
|
||||||
|
/// Berechtigung — und funktioniert deshalb auch, wenn der Mixer aus ist.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class SystemAudio {
|
||||||
|
|
||||||
|
public private(set) var outputDevices: [AudioDevice] = []
|
||||||
|
public private(set) var inputDevices: [AudioDevice] = []
|
||||||
|
|
||||||
|
public private(set) var outputDeviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
public private(set) var inputDeviceID = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
|
||||||
|
public private(set) var outputVolume: Double = 0
|
||||||
|
public private(set) var outputMuted = false
|
||||||
|
public private(set) var inputVolume: Double = 0
|
||||||
|
public private(set) var inputMuted = false
|
||||||
|
/// Warnton-Lautstärke. `nil`, wenn das Gerät sie nicht getrennt führt.
|
||||||
|
public private(set) var alertVolume: Double?
|
||||||
|
|
||||||
|
/// Ob sich die Lautstärke überhaupt setzen lässt.
|
||||||
|
///
|
||||||
|
/// Manche Geräte — HDMI, viele USB-Interfaces — bringen keinen eigenen
|
||||||
|
/// Regler mit; dort regelt man am Gerät. Ein Regler, der nichts tut, wäre
|
||||||
|
/// eine Pseudobedienung.
|
||||||
|
public private(set) var outputIsAdjustable = false
|
||||||
|
public private(set) var inputIsAdjustable = false
|
||||||
|
|
||||||
|
private var listeners: [PropertyListener] = []
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
refresh()
|
||||||
|
observeSystemChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lesen
|
||||||
|
|
||||||
|
public func refresh() {
|
||||||
|
outputDevices = Self.devices().filter(\.isOutput)
|
||||||
|
inputDevices = Self.devices().filter(\.isInput)
|
||||||
|
|
||||||
|
outputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultOutputDevice)
|
||||||
|
inputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||||
|
|
||||||
|
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
|
||||||
|
outputIsAdjustable = Self.volume(outputDeviceID,
|
||||||
|
scope: kAudioDevicePropertyScopeOutput) != nil
|
||||||
|
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
|
||||||
|
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
|
||||||
|
inputIsAdjustable = Self.volume(inputDeviceID,
|
||||||
|
scope: kAudioDevicePropertyScopeInput) != nil
|
||||||
|
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
|
||||||
|
let alertDevice = Self.defaultDevice(kAudioHardwarePropertyDefaultSystemOutputDevice)
|
||||||
|
alertVolume = alertDevice == outputDeviceID
|
||||||
|
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
|
||||||
|
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setzen
|
||||||
|
|
||||||
|
public func setOutputVolume(_ value: Double) {
|
||||||
|
Self.setVolume(value, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
outputVolume = min(max(value, 0), 1)
|
||||||
|
// Lautstärke ziehen hebt die Stummschaltung auf — sonst zieht man am
|
||||||
|
// Regler und hört weiter nichts.
|
||||||
|
if outputMuted, value > 0 { setOutputMuted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setOutputMuted(_ muted: Bool) {
|
||||||
|
Self.setMuted(muted, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
|
||||||
|
outputMuted = muted
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setInputVolume(_ value: Double) {
|
||||||
|
Self.setVolume(value, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
inputVolume = min(max(value, 0), 1)
|
||||||
|
if inputMuted, value > 0 { setInputMuted(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setInputMuted(_ muted: Bool) {
|
||||||
|
Self.setMuted(muted, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
|
||||||
|
inputMuted = muted
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectOutput(_ device: AudioDevice) {
|
||||||
|
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultOutputDevice)
|
||||||
|
// Warntöne folgen dem Ausgabegerät: zwei getrennte Wahlen sind eine
|
||||||
|
// Quelle für „warum kommt der Systemton aus dem Notebook".
|
||||||
|
Self.setDefaultDevice(device.id,
|
||||||
|
selector: kAudioHardwarePropertyDefaultSystemOutputDevice)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func selectInput(_ device: AudioDevice) {
|
||||||
|
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultInputDevice)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio
|
||||||
|
|
||||||
|
/// Gerätewechsel kommen von außen: Kopfhörer einstecken, Bluetooth
|
||||||
|
/// verbinden, ein anderes Programm stellt das Standardgerät um.
|
||||||
|
private func observeSystemChanges() {
|
||||||
|
listeners = [kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
kAudioHardwarePropertyDevices].compactMap { selector in
|
||||||
|
PropertyListener(selector: selector) { [weak self] in
|
||||||
|
Task { @MainActor in self?.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func devices() -> [AudioDevice] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size) == noErr else { return [] }
|
||||||
|
|
||||||
|
var ids = [AudioObjectID](repeating: 0,
|
||||||
|
count: Int(size) / MemoryLayout<AudioObjectID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, &ids) == noErr else { return [] }
|
||||||
|
|
||||||
|
return ids.compactMap { id in
|
||||||
|
guard let name = name(of: id) else { return nil }
|
||||||
|
let output = channels(id, scope: kAudioDevicePropertyScopeOutput) > 0
|
||||||
|
let input = channels(id, scope: kAudioDevicePropertyScopeInput) > 0
|
||||||
|
// Geräte ohne Kanäle sind Aggregate-Hüllen und Ähnliches — sie in
|
||||||
|
// die Auswahl zu stellen hilft niemandem.
|
||||||
|
guard output || input else { return nil }
|
||||||
|
return AudioDevice(id: id, name: name, isOutput: output, isInput: input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func name(of device: AudioObjectID) -> String? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioObjectPropertyName,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
var name: CFString?
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &name) == noErr
|
||||||
|
else { return nil }
|
||||||
|
return name as String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func channels(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Int {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr,
|
||||||
|
size > 0 else { return 0 }
|
||||||
|
|
||||||
|
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||||
|
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { buffer.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, buffer) == noErr
|
||||||
|
else { return 0 }
|
||||||
|
|
||||||
|
let list = UnsafeMutableAudioBufferListPointer(
|
||||||
|
buffer.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return list.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var device = AudioObjectID(kAudioObjectUnknown)
|
||||||
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||||
|
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
|
||||||
|
&address, 0, nil, &size, &device)
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setDefaultDevice(_ device: AudioObjectID,
|
||||||
|
selector: AudioObjectPropertySelector) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var value = device
|
||||||
|
let status = AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<AudioObjectID>.size), &value)
|
||||||
|
if status != noErr {
|
||||||
|
log.error("Ausgabegerät nicht gesetzt: \(status, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Lautstärke eines Geräts.
|
||||||
|
///
|
||||||
|
/// **Zuerst der Hauptregler, dann die Kanäle.** Viele Geräte führen keinen
|
||||||
|
/// Regler für Element 0, sondern nur je Kanal — fragt man nur den
|
||||||
|
/// Hauptregler, hält man ein regelbares Gerät für unregelbar.
|
||||||
|
private static func volume(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Double? {
|
||||||
|
guard device != AudioObjectID(kAudioObjectUnknown) else { return nil }
|
||||||
|
|
||||||
|
for element in [kAudioObjectPropertyElementMain, 1, 2] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: scope, mElement: AudioObjectPropertyElement(element))
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { continue }
|
||||||
|
var value: Float32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float32>.size)
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
|
||||||
|
else { continue }
|
||||||
|
return Double(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setVolume(_ value: Double, on device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) {
|
||||||
|
guard device != AudioObjectID(kAudioObjectUnknown) else { return }
|
||||||
|
var scalar = Float32(min(max(value, 0), 1))
|
||||||
|
|
||||||
|
for element in [kAudioObjectPropertyElementMain, 1, 2] {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyVolumeScalar,
|
||||||
|
mScope: scope, mElement: AudioObjectPropertyElement(element))
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { continue }
|
||||||
|
var settable: DarwinBoolean = false
|
||||||
|
guard AudioObjectIsPropertySettable(device, &address, &settable) == noErr,
|
||||||
|
settable.boolValue else { continue }
|
||||||
|
AudioObjectSetPropertyData(device, &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<Float32>.size), &scalar)
|
||||||
|
// Beim Hauptregler ist man fertig; bei Kanälen müssen beide gesetzt
|
||||||
|
// werden, sonst wandert das Stereobild.
|
||||||
|
if element == kAudioObjectPropertyElementMain { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func muted(_ device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) -> Bool {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { return false }
|
||||||
|
var value: UInt32 = 0
|
||||||
|
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||||
|
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
|
||||||
|
else { return false }
|
||||||
|
return value != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setMuted(_ muted: Bool, on device: AudioObjectID,
|
||||||
|
scope: AudioObjectPropertyScope) {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyMute,
|
||||||
|
mScope: scope, mElement: kAudioObjectPropertyElementMain)
|
||||||
|
guard AudioObjectHasProperty(device, &address) else { return }
|
||||||
|
var value: UInt32 = muted ? 1 : 0
|
||||||
|
AudioObjectSetPropertyData(device, &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<UInt32>.size), &value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Hält eine Core-Audio-Anmeldung und meldet sie beim Abräumen wieder ab.
|
||||||
|
///
|
||||||
|
/// Wie beim Kalender ein eigenes Objekt: `SystemAudio` liegt auf dem
|
||||||
|
/// MainActor, sein `deinit` aber nicht — es dürfte die Anmeldung dort gar
|
||||||
|
/// nicht mehr anfassen. Und `AudioObjectRemovePropertyListenerBlock` will
|
||||||
|
/// **denselben** Block zurück, den man angemeldet hat; der muss also
|
||||||
|
/// irgendwo aufgehoben werden.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable` ist belegbar: nach `init` wird hier nichts mehr
|
||||||
|
/// geschrieben.
|
||||||
|
private final class PropertyListener: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let object = AudioObjectID(kAudioObjectSystemObject)
|
||||||
|
private var address: AudioObjectPropertyAddress
|
||||||
|
private let block: AudioObjectPropertyListenerBlock
|
||||||
|
|
||||||
|
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
|
||||||
|
address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
block = { _, _ in handler() }
|
||||||
|
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
AudioObjectRemovePropertyListenerBlock(object, &address, .main, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user