Files
onyx/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
Scarriffle 8308536227 Phase 8: Ablage, AirDrop und Calendarr
Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:19 +02:00

207 lines
8.0 KiB
Swift

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(_:systemImage:) kennt kein Bundle und sucht immer
// im Hauptbundle - die Strings liegen aber im Modulbundle.
// Ohne den Umweg über Text steht im Knopf mixer.off".
Label {
Text("mixer.off", bundle: .module).font(Onyx.Font.caption)
} icon: {
Image(systemName: "power")
}
}
.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 {
Text("mixer.on", bundle: .module).font(Onyx.Font.caption)
} icon: {
Image(systemName: "slider.horizontal.3")
}
}
.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)
)
}
}