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.
247 lines
9.4 KiB
247 lines
9.4 KiB
import Foundation
|
|
import MusubiCore
|
|
import Observation
|
|
import Remote
|
|
|
|
/// Ties outputs, audio, scenes and remote control together according to the role.
|
|
@MainActor @Observable
|
|
final class Coordinator {
|
|
let settings: AppSettings
|
|
let outputs: OutputController
|
|
let audio: AudioController
|
|
let store = SceneStore()
|
|
let intercom: IntercomController
|
|
|
|
private(set) var live = LiveState()
|
|
private(set) var nodeServer: NodeServer?
|
|
private(set) var browser: NodeBrowser?
|
|
|
|
private var statusTimer: Timer?
|
|
private var cpu = ProcessCPU()
|
|
private var restoring = false
|
|
|
|
init(settings: AppSettings, outputs: OutputController, audio: AudioController) {
|
|
self.settings = settings
|
|
self.outputs = outputs
|
|
self.audio = audio
|
|
intercom = IntercomController(settings: settings, audio: audio)
|
|
audio.onTalkChange = { [weak intercom] active in intercom?.setTalkActive(active) }
|
|
}
|
|
|
|
/// Restores the last live state, starts remote control, then applies launch arguments.
|
|
func start() async {
|
|
live = store.loadLiveState()
|
|
restoring = true
|
|
outputs.apply(live.local.video)
|
|
audio.apply(live.local.audio)
|
|
restoring = false
|
|
Log.ndi.info("Restored \(self.live.sceneName ?? "live state", privacy: .public): \(self.live.local.video.count) video, \(self.live.local.audio.count) audio")
|
|
|
|
outputs.onChange = { [weak self] in self?.localChanged() }
|
|
audio.onChange = { [weak self] in self?.localChanged() }
|
|
await startRemote()
|
|
await intercom.restart()
|
|
|
|
outputs.applyLaunchRoutes()
|
|
audio.applyLaunchRoutes()
|
|
applyLoginItemArgument()
|
|
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--load-scene"),
|
|
index + 1 < ProcessInfo.processInfo.arguments.count {
|
|
let name = ProcessInfo.processInfo.arguments[index + 1]
|
|
if let scene = store.scenes.first(where: { $0.name == name }) { loadScene(scene) }
|
|
else { Log.remote.error("--load-scene: no scene named \(name, privacy: .public)") }
|
|
}
|
|
|
|
statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.sendStatus() }
|
|
}
|
|
}
|
|
|
|
/// `--login-item on|off`: set up a headless node over SSH, where the settings window is hidden behind outputs.
|
|
private func applyLoginItemArgument(_ arguments: [String] = ProcessInfo.processInfo.arguments) {
|
|
guard let index = arguments.firstIndex(of: "--login-item"), index + 1 < arguments.count else { return }
|
|
settings.launchAtLogin = arguments[index + 1] == "on"
|
|
Log.ndi.info("Launch at login: \(self.settings.launchAtLogin ? "on" : "off", privacy: .public)")
|
|
}
|
|
|
|
/// (Re)starts the Bonjour side that fits the role; call after role or interface changes.
|
|
func startRemote() async {
|
|
nodeServer?.stop()
|
|
nodeServer = nil
|
|
browser?.stop()
|
|
browser = nil
|
|
|
|
switch settings.role {
|
|
case .node:
|
|
let server = NodeServer(name: settings.machineName)
|
|
server.onCommand = { [weak self] in self?.handle($0) }
|
|
nodeServer = server
|
|
await server.start(interfaceName: settings.interfaceName)
|
|
case .control:
|
|
let browser = NodeBrowser(name: settings.machineName)
|
|
browser.onNodeConnected = { [weak self] in self?.nodeConnected($0) }
|
|
self.browser = browser
|
|
await browser.start(interfaceName: settings.interfaceName)
|
|
}
|
|
}
|
|
|
|
var remoteError: String? { nodeServer?.error ?? browser?.error }
|
|
|
|
// MARK: - Control: nodes
|
|
|
|
/// Shows a source on a node's screen.
|
|
func show(_ assignment: VideoAssignment, onNode name: String) {
|
|
Log.remote.info("UI: show \(assignment.source.ndiName, privacy: .public) on \(name, privacy: .public)/\(assignment.screen.name, privacy: .public)")
|
|
var config = desiredConfig(forNode: name)
|
|
config.video.removeAll { $0.screen == assignment.screen }
|
|
config.video.append(assignment)
|
|
live.nodes[name] = config
|
|
browser?.node(named: name)?.send(.show(assignment))
|
|
markModified()
|
|
}
|
|
|
|
func stop(_ screen: ScreenRef, onNode name: String) {
|
|
Log.remote.info("UI: stop \(name, privacy: .public)/\(screen.name, privacy: .public)")
|
|
var config = desiredConfig(forNode: name)
|
|
config.video.removeAll { $0.screen == screen }
|
|
live.nodes[name] = config
|
|
browser?.node(named: name)?.send(.stop(screen))
|
|
markModified()
|
|
}
|
|
|
|
private func desiredConfig(forNode name: String) -> MachineConfig {
|
|
live.nodes[name] ?? browser?.node(named: name)?.status?.config ?? .empty
|
|
}
|
|
|
|
/// A node (re)connected: it gets what the control wants, or the control adopts what the node shows.
|
|
private func nodeConnected(_ node: NodeBrowser.Node) {
|
|
if let desired = live.nodes[node.name] {
|
|
node.send(.apply(desired))
|
|
Log.remote.info("Sent configuration to \(node.name, privacy: .public): \(desired.video.count) video")
|
|
} else {
|
|
adoptWhenStatusArrives(node)
|
|
}
|
|
}
|
|
|
|
private func adoptWhenStatusArrives(_ node: NodeBrowser.Node) {
|
|
Task { [weak self] in
|
|
for _ in 0..<10 {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
guard let self, live.nodes[node.name] == nil else { return }
|
|
if let config = node.status?.config {
|
|
Log.remote.info("Adopted configuration of \(node.name, privacy: .public): \(config.video.count) video")
|
|
live.nodes[node.name] = config
|
|
store.saveLiveState(live)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Node: commands from the control
|
|
|
|
private func handle(_ message: RemoteMessage) {
|
|
Log.remote.info("Command: \(String(describing: message).prefix(200), privacy: .public)")
|
|
switch message {
|
|
case .apply(let config):
|
|
outputs.apply(config.video)
|
|
audio.apply(config.audio)
|
|
case .show(let assignment):
|
|
outputs.show(assignment)
|
|
case .stop(let screen):
|
|
outputs.stop(screen)
|
|
case .hello, .status:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func sendStatus() {
|
|
let cpuPercent = cpu.sample()
|
|
guard let nodeServer else { return }
|
|
nodeServer.send(NodeStatus(
|
|
screens: outputs.screenStatuses(),
|
|
config: MachineConfig(video: outputs.desired, audio: audio.currentAudio()),
|
|
cpuPercent: cpuPercent,
|
|
sleepBlocked: outputs.isSleepBlocked
|
|
))
|
|
}
|
|
|
|
// MARK: - Scenes (control)
|
|
|
|
var currentSceneName: String? { live.sceneName }
|
|
var isModified: Bool { live.modified }
|
|
|
|
func saveScene(as name: String? = nil) {
|
|
var scene = Scene(
|
|
id: name == nil ? (live.sceneID ?? UUID()) : UUID(),
|
|
name: name ?? live.sceneName ?? "Szene",
|
|
control: live.local,
|
|
nodes: currentNodeConfigs()
|
|
)
|
|
if let name, let existing = store.scenes.first(where: { $0.name == name }) { scene.id = existing.id }
|
|
store.save(scene)
|
|
live.sceneID = scene.id
|
|
live.sceneName = scene.name
|
|
live.modified = false
|
|
store.saveLiveState(live)
|
|
}
|
|
|
|
func loadScene(_ scene: Scene) {
|
|
restoring = true
|
|
outputs.apply(scene.control.video)
|
|
audio.apply(scene.control.audio)
|
|
restoring = false
|
|
live = LiveState(local: scene.control, nodes: scene.nodes, sceneID: scene.id, sceneName: scene.name, modified: false)
|
|
for (name, config) in scene.nodes {
|
|
browser?.node(named: name)?.send(.apply(config))
|
|
}
|
|
store.saveLiveState(live)
|
|
Log.ndi.info("Scene \(scene.name, privacy: .public) loaded")
|
|
}
|
|
|
|
func exportableScene() -> Scene {
|
|
Scene(id: live.sceneID ?? UUID(), name: live.sceneName ?? "Szene", control: live.local, nodes: currentNodeConfigs())
|
|
}
|
|
|
|
private func currentNodeConfigs() -> [String: MachineConfig] {
|
|
var nodes = live.nodes
|
|
for node in browser?.nodes ?? [] where nodes[node.name] == nil {
|
|
if let config = node.status?.config { nodes[node.name] = config }
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
// MARK: - Live state
|
|
|
|
private func localChanged() {
|
|
live.local = MachineConfig(video: outputs.desired, audio: audio.currentAudio())
|
|
guard !restoring else { return }
|
|
markModified()
|
|
}
|
|
|
|
private func markModified() {
|
|
Log.remote.debug("Live state saved: local \(self.live.local.video.count) video, nodes \(self.live.nodes.mapValues(\.video.count), privacy: .public)")
|
|
if live.sceneID != nil { live.modified = true }
|
|
store.saveLiveState(live)
|
|
}
|
|
}
|
|
|
|
/// CPU usage of this process since the last sample, in percent of one core.
|
|
struct ProcessCPU {
|
|
private var lastCPU = ProcessCPU.cpuSeconds()
|
|
private var lastWall = ProcessInfo.processInfo.systemUptime
|
|
|
|
mutating func sample() -> Double {
|
|
let cpu = Self.cpuSeconds(), wall = ProcessInfo.processInfo.systemUptime
|
|
defer { lastCPU = cpu; lastWall = wall }
|
|
return wall > lastWall ? (cpu - lastCPU) / (wall - lastWall) * 100 : 0
|
|
}
|
|
|
|
static func cpuSeconds() -> Double {
|
|
var usage = rusage()
|
|
getrusage(RUSAGE_SELF, &usage)
|
|
func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 }
|
|
return seconds(usage.ru_utime) + seconds(usage.ru_stime)
|
|
}
|
|
}
|