import DesignSystem import MusubiCore import Remote import SwiftUI /// Local screens and, on the control, the screens of every node. Drop a source on a card to show it there. struct ScreensView: View { let coordinator: Coordinator private var outputs: OutputController { coordinator.outputs } var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { RoleBanner(coordinator: coordinator) SectionTitle(text: coordinator.settings.machineName, detail: coordinator.settings.role == .control ? "Regie" : "Knoten") if outputs.outputs.isEmpty && coordinator.settings.role == .control && (coordinator.browser?.nodes.isEmpty ?? true) { Text("Noch keine Szene. Zieh eine Quelle auf einen Bildschirm.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } CardGrid { ForEach(outputs.screens) { screen in ScreenCard( title: screen.name, detail: Self.format(width: screen.width, height: screen.height, hz: screen.refreshRate), output: outputs.outputs[screen.id].map { OutputCardModel(local: $0) }, tick: outputs.tick, onStop: { outputs.stop(screen.id) } ) .dropDestination(for: String.self) { names, _ in guard let name = names.first, !name.hasPrefix("input:") else { return false } outputs.show(Source(ndiName: name), on: screen.id) return true } } } if let browser = coordinator.browser { ForEach(browser.nodes) { node in NodeSection(coordinator: coordinator, node: node) } } } .padding(Theme.Space.card) } .background(Theme.Color.sumi) } static func format(width: Int, height: Int, hz: Double) -> String { "\(width) × \(height) · \(hz.formatted(.number.precision(.fractionLength(0...2)))) Hz" } } private struct RoleBanner: View { let coordinator: Coordinator var body: some View { if let error = coordinator.remoteError { Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku) } else if coordinator.settings.role == .node, let server = coordinator.nodeServer { Text(server.controlName.map { "Knoten · gesteuert von \($0)" } ?? "Knoten · wartet auf die Regie") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } } } private struct NodeSection: View { let coordinator: Coordinator let node: NodeBrowser.Node var body: some View { VStack(alignment: .leading, spacing: Theme.Space.card) { HStack(spacing: Theme.Space.unit) { Circle() .fill(node.isConnected ? Theme.Color.moegi : Theme.Color.kohaku) .frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot) SectionTitle(text: node.name, detail: "Knoten") Spacer() if let status = node.status, node.isConnected { Text(verbatim: "CPU \(Int(status.cpuPercent.rounded())) %") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) if status.sleepBlocked { Text("Ruhezustand blockiert").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) } } } if !node.isConnected { Text("\(node.name) antwortet nicht. Neuer Versuch läuft.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.kohaku) } CardGrid { ForEach(node.status?.screens ?? []) { screen in ScreenCard( title: screen.screen.name, detail: ScreensView.format(width: screen.width, height: screen.height, hz: screen.refreshRate), output: screen.output.map { OutputCardModel(remote: $0) }, tick: 0, onStop: { coordinator.stop(screen.screen, onNode: node.name) } ) .opacity(node.isConnected ? 1 : 0.5) .dropDestination(for: String.self) { names, _ in guard let name = names.first, !name.hasPrefix("input:") else { return false } coordinator.show(VideoAssignment(screen: screen.screen, source: Source(ndiName: name)), onNode: node.name) return true } } } } .padding(.top, Theme.Space.card) } } private struct SectionTitle: View { let text: String let detail: LocalizedStringKey var body: some View { HStack(alignment: .firstTextBaseline, spacing: Theme.Space.unit) { Text(verbatim: text).font(Theme.Font.title).foregroundStyle(Theme.Color.washi) Text(detail).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) } } } private struct CardGrid: View { @ViewBuilder let content: Content var body: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) { content } } } /// What a screen card shows about an output, for local and remote screens alike. struct OutputCardModel { var label: String var state: OutputStatus.State var retryIn: Double? var width: Int var height: Int var frameRate: Double var lostFrames: Int64 var lossRate: Double @MainActor init(local output: ActiveOutput) { let s = output.snapshot label = output.source.label switch s.state { case .connecting: state = .connecting case .live: state = .live case .stalled(let retry): state = .stalled; retryIn = retry } width = s.width height = s.height frameRate = s.frameRate lostFrames = s.stats.missing + s.stats.droppedByReceiver lossRate = s.stats.lossRate } init(remote status: OutputStatus) { label = status.source.label state = status.state width = status.width height = status.height frameRate = status.frameRate lostFrames = status.lostFrames lossRate = status.lossRate } } private struct ScreenCard: View { let title: String let detail: String let output: OutputCardModel? let tick: Int let onStop: () -> Void var body: some View { VStack(alignment: .leading, spacing: Theme.Space.unit) { Text(verbatim: title) .font(Theme.Font.title) .foregroundStyle(Theme.Color.washi) Text(verbatim: detail) .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) Divider().overlay(Theme.Color.usuzumi) if let output { OutputStatusView(output: output) 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 OutputStatusView: View { let output: OutputCardModel var body: some View { VStack(alignment: .leading, spacing: Theme.Space.unit / 2) { Text(verbatim: output.label) .font(Theme.Font.body) .foregroundStyle(Theme.Color.washi) switch output.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: "\(output.width) × \(output.height) · \(output.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) } case .stalled: if let retry = output.retryIn { Text("\(output.label) antwortet nicht mehr. Neuer Versuch in \(Int(retry.rounded(.up))) s.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.kohaku) } else { Text("\(output.label) antwortet nicht mehr.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.kohaku) } } Text("\(output.lostFrames) Frames verloren (\(output.lossRate.formatted(.percent.precision(.fractionLength(2)))))") .font(Theme.Font.number) .foregroundStyle(output.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi) } } }