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