import DesignSystem import MusubiCore import SwiftUI /// Cards for all connected screens; drop a source on a card to show it fullscreen there. struct ScreensView: View { let outputs: OutputController var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { Text("Bildschirme") .font(Theme.Font.title) .foregroundStyle(Theme.Color.washi) if outputs.outputs.isEmpty { Text("Noch keine Szene. Zieh eine Quelle auf einen Bildschirm.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) { ForEach(outputs.screens) { screen in ScreenCard(screen: screen, output: outputs.outputs[screen.id], tick: outputs.tick) { outputs.stop(screen.id) } .dropDestination(for: String.self) { names, _ in guard let name = names.first else { return false } outputs.show(Source(ndiName: name), on: screen.id) return true } } } } .padding(Theme.Space.card) } .background(Theme.Color.sumi) } } private struct ScreenCard: View { let screen: ScreenInfo let output: ActiveOutput? let tick: Int let onStop: () -> Void var body: some View { VStack(alignment: .leading, spacing: Theme.Space.unit) { Text(verbatim: screen.name) .font(Theme.Font.title) .foregroundStyle(Theme.Color.washi) Text(verbatim: "\(screen.width) × \(screen.height) · \(screen.refreshRate.formatted(.number.precision(.fractionLength(0...2)))) Hz") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) Divider().overlay(Theme.Color.usuzumi) if let output { OutputStatus(output: output, tick: tick) Button("Ausgabe beenden", action: onStop) .buttonStyle(.bordered) } else { Text("Quelle hierher ziehen") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } } .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading) .padding(Theme.Space.card) .background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.card) .strokeBorder(output == nil ? Theme.Color.usuzumi : Theme.Color.ai, lineWidth: output == nil ? Theme.Stroke.line : Theme.Stroke.cord) ) } } private struct OutputStatus: View { let output: ActiveOutput let tick: Int var body: some View { let snapshot = output.snapshot VStack(alignment: .leading, spacing: Theme.Space.unit / 2) { Text(verbatim: output.source.label) .font(Theme.Font.body) .foregroundStyle(Theme.Color.washi) switch snapshot.state { case .connecting: Text("Verbinde …") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.nezumi) case .live: HStack(spacing: Theme.Space.unit / 2) { Circle().fill(Theme.Color.moegi).frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot) Text(verbatim: "\(snapshot.width) × \(snapshot.height) · \(snapshot.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) } case .stalled(let retryIn): Text("\(output.source.label) antwortet nicht mehr. Neuer Versuch in \(Int(retryIn.rounded(.up))) s.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.kohaku) } let lost = snapshot.stats.missing + snapshot.stats.droppedByReceiver Text("\(lost) Frames verloren (\(snapshot.stats.lossRate.formatted(.percent.precision(.fractionLength(2)))))") .font(Theme.Font.number) .foregroundStyle(snapshot.stats.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi) } } }