import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. public struct MixerView: View { let mixer: AudioMixer /// Im Panel ist es eng, im Popover nicht. let compact: Bool public init(mixer: AudioMixer, compact: Bool = false) { self.mixer = mixer self.compact = compact } public var body: some View { VStack(alignment: .leading, spacing: compact ? 6 : 10) { if mixer.isEnabled { if mixer.processes.isEmpty { Text("mixer.noProcesses", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) } else { ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in ProcessRow(mixer: mixer, process: process, compact: compact) } } if let error = mixer.lastError { Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical) .fixedSize(horizontal: false, vertical: true) } Divider().overlay(Onyx.Color.hairline) // Der Notausschalter gehört sichtbar dazu, nicht in ein Menü: // wenn der Ton weg ist, ist das der Knopf, den man sucht. Button { mixer.disable() } label: { Label("mixer.off", systemImage: "power") .font(Onyx.Font.caption) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textSecondary) } else { OffState(mixer: mixer, compact: compact) } } .frame(maxWidth: .infinity, alignment: .leading) } } /// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt. private struct OffState: View { let mixer: AudioMixer let compact: Bool var body: some View { VStack(alignment: .leading, spacing: 6) { Text("mixer.off.explain", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .fixedSize(horizontal: false, vertical: true) if !compact { Text("mixer.off.warning", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) .fixedSize(horizontal: false, vertical: true) } Button { mixer.enable() } label: { Label("mixer.on", systemImage: "slider.horizontal.3").font(Onyx.Font.caption) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.accent) } } } private struct ProcessRow: View { let mixer: AudioMixer let process: AudioProcess let compact: Bool var body: some View { let managed = mixer.isManaged(process.bundleID) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { icon Text(process.name) .font(Onyx.Font.caption) .foregroundStyle(process.isPlaying ? Onyx.Color.textPrimary : Onyx.Color.textSecondary) .lineLimit(1) // In der Mitte kürzen statt am Ende: bei zwei ähnlichen // Namen ist das Ende oft das Unterscheidende. .truncationMode(.middle) .layoutPriority(1) if process.isPlaying { Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) } Spacer(minLength: 0) if managed { Button { mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID) } label: { Image(systemName: mixer.isMuted(process.bundleID) ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: 10)) } .buttonStyle(.plain) .foregroundStyle(mixer.isMuted(process.bundleID) ? Onyx.Color.critical : Onyx.Color.textTertiary) } // Erst wenn ein Programm ausgewählt ist, wird sein Ton // abgegriffen. Alles andere läuft unverändert am Mixer vorbei. Toggle("", isOn: Binding( get: { managed }, set: { mixer.setManaged($0, for: process.bundleID) })) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() } if managed { // Die Prozentzahl steht beim Regler, nicht beim Namen: oben ist // sie die Breite, die den Namen abschneidet, hier gehört sie // ohnehin hin. HStack(spacing: 6) { Slider(value: Binding( get: { mixer.volume(for: process.bundleID) }, set: { mixer.setVolume($0, for: process.bundleID) }), in: 0...Gain.maximumVolume) .controlSize(.mini) .disabled(mixer.isMuted(process.bundleID)) .overlay(alignment: .leading) { // Markierung bei 100 %: darüber wird verstärkt, und // man soll sehen, wann man diese Grenze überschreitet. GeometryReader { geometry in Rectangle() .fill(Onyx.Color.hairline) .frame(width: 1, height: 8) .offset(x: geometry.size.width / Gain.maximumVolume, y: 4) } } Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %") .font(Onyx.Font.metricSmall) .foregroundStyle(mixer.volume(for: process.bundleID) > 1 ? Onyx.Color.warning : Onyx.Color.textTertiary) // Feste Breite, sonst wandert der Regler beim Ziehen. .frame(width: 34, alignment: .trailing) } } } } @ViewBuilder private var icon: some View { // Auch Hilfsprozesse bekommen das Symbol ihres Programms — sonst steht // neben „Google Chrome" ein leerer Platzhalter. if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) { Image(nsImage: image).resizable().frame(width: 14, height: 14) } else { Image(systemName: "app.dashed") .font(.system(size: 11)) .foregroundStyle(Onyx.Color.textTertiary) } } } // MARK: - Widget public struct AudioMixerWidget: OnyxWidget { public let id = "audio" public var displayName: String { String(localized: "mixer.name", bundle: .module) } public let symbolName = "slider.horizontal.3" public let supportedSizes: [WidgetSize] = [.medium, .large] private let mixer: AudioMixer public init(mixer: AudioMixer) { self.mixer = mixer } public func makeView(size: WidgetSize) -> AnyView { AnyView( ScrollView { MixerView(mixer: mixer, compact: size == .medium) } .scrollIndicators(.never) ) } }