|
|
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<Content: View>: 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)
|
|
|
}
|
|
|
}
|
|
|
}
|