You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/App/Sources/ScreensView.swift

253 lines
9.8 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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)
}
}
}

Powered by TurnKey Linux.