import AudioMatrix import DesignSystem import MusubiCore import SwiftUI /// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip. struct SoundView: View { let audio: AudioController let sourceList: SourceListModel var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { header if let device = audio.selectedDevice { if let error = audio.matrix.errors[device.uid] { Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku) } if audio.matrix.routes.isEmpty { Text("Noch kein Ton geroutet. Zieh eine Quelle oder ein Mikrofon auf einen Kanal.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: Theme.Space.card)], spacing: Theme.Space.card) { ForEach(0.. 0 { Text("\(count) Aussetzer").font(Theme.Font.caption).foregroundStyle(Theme.Color.kohaku) } } .padding(.vertical, Theme.Space.unit / 2) .contextMenu { Button("Pegel auf 0 dB") { audio.setGain(route.id, db: 0) } Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.setMuted(route.id, !route.muted) } Divider() Button("Route entfernen", role: .destructive) { audio.remove(route.id) } } } } /// Horizontal peak meter; amber close to 0 dBFS. private struct LevelMeter: View { let db: Float var body: some View { GeometryReader { geometry in let fraction = CGFloat((max(db, Level.floorDB) - Level.floorDB) / -Level.floorDB) ZStack(alignment: .leading) { Capsule().fill(Theme.Color.usuzumi) Capsule() .fill(db > -3 ? Theme.Color.kohaku : Theme.Color.washi.opacity(0.7)) .frame(width: geometry.size.width * fraction) } } .frame(height: 4) } } private struct LevelText: View { let db: Float var body: some View { Text(verbatim: db <= Level.floorDB ? "−∞" : String(format: "%.0f dBFS", db)) .font(Theme.Font.number) .foregroundStyle(db > -3 ? Theme.Color.kohaku : Theme.Color.nezumi) } } /// Large push-to-talk button; red only while talking. private struct TalkButton: View { let talkKey: TalkKey @State private var pressing = false var body: some View { VStack(alignment: .trailing, spacing: Theme.Space.unit) { VStack(spacing: Theme.Space.unit / 2) { Image(systemName: talkKey.isActive ? "mic.fill" : "mic") .font(.system(size: 28, weight: .semibold)) Text("Sprechen").font(Theme.Font.title) Text(verbatim: talkKey.displayName).font(Theme.Font.caption) } .foregroundStyle(Theme.Color.washi) .frame(width: 160, height: 120) .background(talkKey.isActive ? Theme.Color.shu : Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card)) .overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).strokeBorder(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line)) .contentShape(RoundedRectangle(cornerRadius: Theme.Radius.card)) .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in guard !pressing else { return } pressing = true talkKey.press() } .onEnded { _ in pressing = false talkKey.release() } ) Picker("Sprechtaste", selection: Binding(get: { talkKey.mode }, set: { talkKey.mode = $0 })) { Text("Gedrückt halten").tag(TalkKey.Mode.hold) Text("Umschalten").tag(TalkKey.Mode.toggle) } .labelsHidden() .pickerStyle(.segmented) .fixedSize() } } }