„Am Netzteil" stand rechts vom Ring, „Am Netz, lädt nicht" darunter — zweimal dieselbe Auskunft an zwei Stellen. Und die zweite stand allein unter dem Diagramm, wo sie aussah, als gehörte sie zu dessen Beschriftung. Jetzt steht beides neben dem Ring: die Schätzung und darunter der Grund dafür. Am Netz entfällt die Ersatzzeile „Am Netzteil" ganz — die Zeile darunter sagt ohnehin, was los ist. Im Akkubetrieb ohne Schätzung bleibt „wird berechnet". Dazu: der Rücksetzknopf im Mixer schaltet den Overdrive mit ab. Er ist keine eigene Einstellung, sondern die Freigabe für einen Wert, den es danach nicht mehr gibt — bliebe er stehen, zeigte die Zeile 100 % und der Regler stünde trotzdem im Boost-Bereich. Der Knopf erscheint deshalb auch, wenn nur der Overdrive an ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
624 lines
24 KiB
Swift
624 lines
24 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import OnyxDesign
|
||
import OnyxWidgetKit
|
||
import CoreAudio
|
||
|
||
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
|
||
///
|
||
/// Die Anordnung folgt SoundSource, weil sie dort gut gelöst ist: eine Zeile je
|
||
/// Programm, links das Symbol als Kachel, dann ein Stummschalter, dann der
|
||
/// Regler, rechts der Wert. Nichts steht übereinander, nichts muss man suchen.
|
||
///
|
||
/// Die Farben folgen ihr **nicht**. SoundSource färbt jeden Regler grün; in
|
||
/// Onyx ist Farbe ein Signal — die Verstärkung über 100 % ist eines, eine
|
||
/// gewöhnliche Lautstärke nicht.
|
||
public struct MixerView: View {
|
||
let mixer: AudioMixer
|
||
let system: SystemAudio
|
||
/// Im Panel ist es eng, im Popover nicht.
|
||
let compact: Bool
|
||
|
||
public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) {
|
||
self.mixer = mixer
|
||
self.system = system
|
||
self.compact = compact
|
||
}
|
||
|
||
public var body: some View {
|
||
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.groups.isEmpty {
|
||
Text("mixer.noProcesses", bundle: .module)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.padding(.vertical, 8)
|
||
} else {
|
||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||
id: \.element.id) { index, group in
|
||
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
||
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, compact ? 6 : 8)
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
|
||
if let error = mixer.lastError {
|
||
Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
autostartToggle
|
||
|
||
// 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: {
|
||
Text("mixer.off", bundle: .module)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, 2)
|
||
} else {
|
||
OffState(mixer: mixer, compact: compact)
|
||
autostartToggle
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||
}
|
||
}
|
||
|
||
extension MixerView {
|
||
/// Das Häkchen steht in **beiden** Zuständen: eingeschaltet, um es wieder
|
||
/// abzuwählen, ausgeschaltet, um es gleich mit zu setzen.
|
||
@ViewBuilder
|
||
var autostartToggle: some View {
|
||
Toggle(isOn: Binding(get: { mixer.startsAutomatically },
|
||
set: { mixer.startsAutomatically = $0 })) {
|
||
Text("mixer.autostart", bundle: .module)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
}
|
||
.toggleStyle(.checkbox)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
|
||
/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt — in zwei
|
||
/// Sätzen, nicht in einem Absatz. Wer vier Zeilen lesen muss, bevor er einen
|
||
/// Regler sieht, liest sie nicht.
|
||
private struct OffState: View {
|
||
let mixer: AudioMixer
|
||
let compact: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.font(.system(size: 15, weight: .light))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
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: {
|
||
Text("mixer.on", bundle: .module)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.surface)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(Onyx.Color.accent, in: .capsule)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ProcessRow: View {
|
||
let mixer: AudioMixer
|
||
let group: AudioProcessGroup
|
||
let compact: Bool
|
||
|
||
private var managed: Bool { mixer.isManaged(group.bundleID) }
|
||
private var muted: Bool { mixer.isMuted(group.bundleID) }
|
||
private var volume: Double { mixer.volume(for: group.bundleID) }
|
||
private var overdriven: Bool { mixer.isOverdriven(group.bundleID) }
|
||
|
||
var body: some View {
|
||
// Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name
|
||
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
|
||
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
|
||
// hält den Zeiger auf das Symbol.
|
||
HStack(spacing: 10) {
|
||
icon
|
||
|
||
Button {
|
||
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||
mixer.setMuted(!muted, for: group.bundleID)
|
||
} label: {
|
||
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||
.font(.system(size: 11))
|
||
.frame(width: 15, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(muted ? Onyx.Color.critical
|
||
: (managed ? Onyx.Color.textSecondary
|
||
: Onyx.Color.textTertiary))
|
||
|
||
VolumeBar(
|
||
volume: volume,
|
||
maximum: Gain.maximum(overdrive: overdriven),
|
||
level: mixer.level(for: group.bundleID),
|
||
isActive: managed && !muted,
|
||
onChange: { newValue in
|
||
// Am Regler zu ziehen **ist** die Entscheidung, dieses
|
||
// Programm zu regeln. Ein zusätzlicher Schalter davor wäre
|
||
// eine Hürde, die nichts schützt — der große Ausschalter
|
||
// unten stellt ohnehin alles zurück.
|
||
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||
mixer.setVolume(newValue, for: group.bundleID)
|
||
})
|
||
|
||
Text(managed ? "\(Int(volume * 100)) %" : "–")
|
||
.font(Onyx.Font.metricSmall)
|
||
.monospacedDigit()
|
||
.foregroundStyle(volume > 1 && managed ? Onyx.Color.warning
|
||
: Onyx.Color.textTertiary)
|
||
// Feste Breite, sonst wandert der Regler beim Ziehen.
|
||
.frame(width: 36, alignment: .trailing)
|
||
|
||
overdriveButton
|
||
resetButton
|
||
}
|
||
.contentShape(.rect)
|
||
.contextMenu {
|
||
if managed {
|
||
Button { mixer.setManaged(false, for: group.bundleID) } label: {
|
||
Text("mixer.release", bundle: .module)
|
||
}
|
||
}
|
||
}
|
||
.animation(Onyx.Motion.value, value: managed)
|
||
}
|
||
|
||
/// Der Overdrive-Schalter.
|
||
///
|
||
/// Er steht bei jedem Programm, auch bei einem ungeregelten — sonst müsste
|
||
/// man erst den Regler anfassen, um an den Schalter zu kommen, und hätte
|
||
/// damit schon geregelt. Eingeschaltet reicht der Regler bis 400 %.
|
||
@ViewBuilder
|
||
private var overdriveButton: some View {
|
||
Button {
|
||
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||
} label: {
|
||
Image(systemName: "bolt.fill")
|
||
.font(.system(size: 9, weight: .medium))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
||
.help(Text("mixer.overdrive", bundle: .module))
|
||
.frame(width: 14, height: 14)
|
||
}
|
||
|
||
/// Zurück auf 100 %.
|
||
///
|
||
/// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu
|
||
/// sehen ist. Ein Knopf, der auftaucht und dabei den Regler schmaler macht,
|
||
/// verschiebt genau das, was man gerade bedient — und beim Loslassen springt
|
||
/// es zurück.
|
||
@ViewBuilder
|
||
private var resetButton: some View {
|
||
Group {
|
||
if managed, abs(volume - 1) > 0.001 || overdriven {
|
||
Button {
|
||
// Der Overdrive geht mit zurück: er ist keine eigene
|
||
// Einstellung, sondern die Freigabe für einen Wert, den es
|
||
// danach nicht mehr gibt. Bliebe er stehen, zeigte die Zeile
|
||
// 100 % und der Regler stünde trotzdem im Boost-Bereich.
|
||
mixer.setOverdrive(false, for: group.bundleID)
|
||
mixer.setVolume(1, for: group.bundleID)
|
||
} label: {
|
||
Image(systemName: "arrow.counterclockwise")
|
||
.font(.system(size: 9, weight: .medium))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.help(Text("mixer.reset", bundle: .module))
|
||
} else {
|
||
Color.clear
|
||
}
|
||
}
|
||
.frame(width: Self.resetSlotWidth, height: 14)
|
||
}
|
||
|
||
/// Die reservierte Breite des Rücksetzknopfes.
|
||
private static let resetSlotWidth: CGFloat = 14
|
||
|
||
/// Wie viele Wellen am Lautsprecher — folgt der Lautstärke, wie überall
|
||
/// sonst in macOS auch.
|
||
private var speakerSymbol: String {
|
||
switch volume {
|
||
case ..<0.01: "speaker.fill"
|
||
case ..<0.5: "speaker.wave.1.fill"
|
||
case ..<1.2: "speaker.wave.2.fill"
|
||
default: "speaker.wave.3.fill"
|
||
}
|
||
}
|
||
|
||
/// Ohne Namen daneben trägt das Symbol die Erkennung allein — also größer
|
||
/// und ohne Kachel, damit es tatsächlich zu erkennen ist.
|
||
@ViewBuilder
|
||
private var icon: some View {
|
||
// Auch Hilfsprozesse bekommen das Symbol ihres Programms.
|
||
Group {
|
||
if let image = ProcessNaming.icon(bundleID: group.bundleID,
|
||
pid: group.processes.first?.pid ?? 0) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.opacity(managed ? 1 : 0.65)
|
||
} else {
|
||
Image(systemName: "app.dashed")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
}
|
||
.frame(width: 26, height: 26)
|
||
// Der Name hängt am Symbol, nicht an der Zeile: die Zeile besteht zum
|
||
// größten Teil aus Regler und Knöpfen, und die verschlucken den
|
||
// Hinweis. Am Symbol kommt er an — und dort sucht man ihn auch, wenn
|
||
// man das Programm nicht erkennt.
|
||
.help(tooltip)
|
||
}
|
||
|
||
/// Name des Programms, bei mehreren Tonquellen mit ihrer Anzahl.
|
||
///
|
||
/// Chrome erscheint als eine Zeile, gibt seinen Ton aber über mehrere
|
||
/// Hilfsprozesse aus. Die Zahl erklärt, warum ein Regler mehr als einen
|
||
/// Prozess bewegt.
|
||
private var tooltip: String {
|
||
guard group.processes.count > 1 else { return group.name }
|
||
return "\(group.name) (\(group.processes.count))"
|
||
}
|
||
}
|
||
|
||
/// Der Lautstärkeregler.
|
||
///
|
||
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
|
||
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
|
||
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
|
||
/// die Grenze bei 100 %.
|
||
private struct VolumeBar: View {
|
||
let volume: Double
|
||
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
|
||
let maximum: Double
|
||
let level: Double
|
||
let isActive: Bool
|
||
let onChange: (Double) -> Void
|
||
|
||
@State private var isDragging = false
|
||
|
||
private let trackHeight: CGFloat = 6
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let width = geometry.size.width
|
||
let fraction = min(max(volume / maximum, 0), 1)
|
||
let knob: CGFloat = isDragging ? 13 : 11
|
||
// Der Knopf soll an den Enden nicht überstehen.
|
||
let travel = max(width - knob, 1)
|
||
let fillWidth = travel * fraction + knob / 2
|
||
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(Onyx.Color.surface)
|
||
.frame(height: trackHeight)
|
||
|
||
// Markierung bei 100 %: darüber wird verstärkt, und man soll
|
||
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
|
||
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
|
||
if maximum > 1 {
|
||
Rectangle()
|
||
.fill(Onyx.Color.hairline)
|
||
.frame(width: 1, height: trackHeight + 4)
|
||
.offset(x: knob / 2 + travel / maximum)
|
||
}
|
||
|
||
Capsule()
|
||
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
||
.frame(width: fillWidth, height: trackHeight)
|
||
.opacity(isActive ? 1 : 0.3)
|
||
|
||
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
|
||
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
|
||
// dieselbe Auskunft.
|
||
if isActive, level > 0.001 {
|
||
Capsule()
|
||
.fill(.white.opacity(0.4))
|
||
.frame(width: fillWidth * meterFraction, height: trackHeight)
|
||
.blendMode(.plusLighter)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
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
|
||
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
|
||
onChange(position * maximum)
|
||
}
|
||
.onEnded { _ in isDragging = false })
|
||
}
|
||
.frame(height: 16)
|
||
.animation(Onyx.Motion.value, value: isDragging)
|
||
}
|
||
|
||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
|
||
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
||
private var meterFraction: Double {
|
||
min(1, sqrt(min(max(level, 0), 1)))
|
||
}
|
||
}
|
||
|
||
// 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"
|
||
|
||
private let mixer: AudioMixer
|
||
private let system: SystemAudio
|
||
|
||
public init(mixer: AudioMixer, system: SystemAudio) {
|
||
self.mixer = mixer
|
||
self.system = system
|
||
}
|
||
|
||
public func makeView() -> AnyView {
|
||
AnyView(
|
||
ScrollView {
|
||
MixerView(mixer: mixer, system: system, compact: true)
|
||
}
|
||
.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)
|
||
}
|
||
}
|