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/OutputController.swift

264 lines
10 KiB

import AppKit
import MusubiCore
import NDIKit
import Remote
import Observation
import VideoOutput
/// A connected screen as shown in the UI.
struct ScreenInfo: Identifiable, Equatable {
let id: CGDirectDisplayID
let name: String
let width: Int
let height: Int
var refreshRate: Double
}
/// One source shown fullscreen on one screen.
@MainActor
final class ActiveOutput {
let source: Source
let receiver: NDIReceiver
let window: OutputWindow
let displayID: CGDirectDisplayID
/// Mode before Musubi switched to 50 Hz; restored when the output ends.
var originalMode: CGDisplayMode?
var snapshot: NDIReceiver.Snapshot
init(source: Source, receiver: NDIReceiver, window: OutputWindow, displayID: CGDirectDisplayID, originalMode: CGDisplayMode?) {
self.source = source
self.receiver = receiver
self.window = window
self.displayID = displayID
self.originalMode = originalMode
self.snapshot = receiver.snapshot
}
}
@MainActor @Observable
final class OutputController {
static let targetRefreshRate = 50.0
private(set) var screens: [ScreenInfo] = []
private(set) var outputs: [CGDirectDisplayID: ActiveOutput] = [:]
/// What should be shown. Assignments for screens that are not connected stay here and start
/// as soon as the screen appears (monitors often come up late after a reboot).
private(set) var desired: [VideoAssignment] = []
/// Called after the desired configuration changed (for autosave and remote status).
var onChange: (() -> Void)?
/// Bumped once per second so views re-read the receiver snapshots.
private(set) var tick = 0
private var sleepBlocker: SleepBlocker?
private var timer: Timer?
private let recorder = StatsRecorder()
var isSleepBlocked: Bool { sleepBlocker != nil }
init() {
refreshScreens()
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in
// Deferred: the notification can arrive synchronously inside a display mode switch.
DispatchQueue.main.async { MainActor.assumeIsolated { self?.screensChanged() } }
}
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.updateSnapshots() }
}
}
/// Shows `source` on a screen and remembers it.
func show(_ source: Source, on displayID: CGDirectDisplayID) {
guard let ref = screenRef(for: displayID) else { return }
show(VideoAssignment(screen: ref, source: source))
}
func show(_ assignment: VideoAssignment) {
desired.removeAll { $0.screen == assignment.screen }
desired.append(assignment)
reconcile()
onChange?()
}
/// Stops the output on a screen and forgets it.
func stop(_ displayID: CGDirectDisplayID) {
guard let ref = screenRef(for: displayID) else { return halt(displayID) }
stop(ref)
}
func stop(_ ref: ScreenRef) {
let names = NSScreen.screens.map(\.localizedName)
let index = ScreenMatching.index(of: ref, in: names)
desired.removeAll { $0.screen == ref || (index != nil && ScreenMatching.index(of: $0.screen, in: names) == index) }
reconcile()
onChange?()
}
/// Replaces the whole video configuration (scene load, remote apply, restore).
func apply(_ video: [VideoAssignment]) {
desired = video
reconcile()
onChange?()
}
private var reconciling = false
private var reconcileAgain = false
/// Starts and stops outputs so that connected screens match `desired`.
/// Never nested: display mode switches post screen-change notifications while we are in here.
private func reconcile() {
guard !reconciling else {
reconcileAgain = true
return
}
reconciling = true
defer { reconciling = false }
repeat {
reconcileAgain = false
reconcileOnce()
} while reconcileAgain
}
private func reconcileOnce() {
let screens = NSScreen.screens
let resolved = ScreenMatching.resolve(desired, in: screens.map(\.localizedName))
let wanted = Dictionary(uniqueKeysWithValues: resolved.map { (screens[$0.key].displayID, $0.value) })
let current = outputs.mapValues(\.source)
let plan = Reconcile.plan(current: current, desired: wanted)
plan.stop.forEach(halt)
for (displayID, source) in plan.start {
start(source, on: displayID)
}
}
func screenRef(for displayID: CGDirectDisplayID) -> ScreenRef? {
guard let index = NSScreen.screens.firstIndex(where: { $0.displayID == displayID }) else { return nil }
return ScreenRef(name: NSScreen.screens[index].localizedName, index: index)
}
/// Screens with their outputs, for the remote status.
func screenStatuses() -> [ScreenStatus] {
screens.compactMap { screen in
guard let ref = screenRef(for: screen.id) else { return nil }
let output = outputs[screen.id].map { output -> OutputStatus in
let s = output.snapshot
let state: OutputStatus.State = switch s.state {
case .connecting: .connecting
case .live: .live
case .stalled: .stalled
}
return OutputStatus(source: output.source, state: state, width: s.width, height: s.height, frameRate: s.frameRate,
lostFrames: s.stats.missing + s.stats.droppedByReceiver, lossRate: s.stats.lossRate)
}
return ScreenStatus(screen: ref, width: screen.width, height: screen.height, refreshRate: screen.refreshRate, output: output)
}
}
private func start(_ source: Source, on displayID: CGDirectDisplayID) {
guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return }
// Switching sources on a screen keeps its 50 Hz mode instead of bouncing to 60 Hz and back.
let previous = outputs.removeValue(forKey: displayID)
previous?.receiver.stop()
let window = OutputWindow(screen: screen)
let sink = window.sink
let receiverName = "Musubi \(screen.localizedName)"
guard let receiver = NDIReceiver(source: source, receiverName: receiverName, onFrame: { sink.display($0) }) else {
Log.ndi.error("Could not create receiver for \(source.ndiName, privacy: .public)")
previous?.window.orderOut(nil)
if let original = previous?.originalMode { DisplayModeSwitcher.restore(original, on: displayID) }
return
}
window.onCancel = { [weak self] in self?.stop(displayID) }
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()
previous?.window.orderOut(nil)
// Register before the mode switch: it posts a screen-change notification.
let output = ActiveOutput(source: source, receiver: receiver, window: window, displayID: displayID, originalMode: previous?.originalMode)
outputs[displayID] = output
let mode = DisplayModeSwitcher.setRefreshRate(Self.targetRefreshRate, on: displayID)
if output.originalMode == nil { output.originalMode = mode.original }
window.setFrame(screen.frame, display: true)
Log.ndi.info("Output \(source.ndiName, privacy: .public) → \(screen.localizedName, privacy: .public) @ \(mode.refreshRate) Hz")
updateSleepBlocker()
refreshScreens()
}
private func halt(_ displayID: CGDirectDisplayID) {
guard let output = outputs.removeValue(forKey: displayID) else { return }
output.receiver.stop()
output.window.orderOut(nil)
if let original = output.originalMode {
DisplayModeSwitcher.restore(original, on: displayID)
}
Log.ndi.info("Output stopped on display \(displayID)")
updateSleepBlocker()
refreshScreens()
}
/// Applies `--route "<NDI name>=<screen index>"` launch arguments.
func applyLaunchRoutes(_ arguments: [String] = ProcessInfo.processInfo.arguments) {
for route in RouteArgument.parse(arguments) {
guard route.screenIndex < NSScreen.screens.count else {
Log.ndi.error("--route: no screen \(route.screenIndex)")
continue
}
show(Source(ndiName: route.ndiName), on: NSScreen.screens[route.screenIndex].displayID)
}
}
private func updateSnapshots() {
for output in outputs.values {
output.snapshot = output.receiver.snapshot
}
recorder.recordIfDue(outputs: Array(outputs.values), screens: screens)
tick += 1
}
private func updateSleepBlocker() {
if outputs.isEmpty {
sleepBlocker = nil
} else if sleepBlocker == nil {
sleepBlocker = SleepBlocker(reason: "Musubi: Ausgabe aktiv")
}
}
private func screensChanged() {
// Keep windows covering their screen after resolution changes.
for output in outputs.values {
if let screen = NSScreen.screens.first(where: { $0.displayID == output.displayID }) {
output.window.setFrame(screen.frame, display: true)
}
}
let connected = Set(NSScreen.screens.map(\.displayID))
for displayID in outputs.keys where !connected.contains(displayID) {
Log.ndi.notice("Display \(displayID) disconnected, output paused until it returns")
halt(displayID)
}
// Screens that came (back) get their remembered output.
reconcile()
refreshScreens()
}
private func refreshScreens() {
screens = NSScreen.screens.map { screen in
let displayID = screen.displayID
return ScreenInfo(
id: displayID,
name: screen.localizedName,
width: Int(CGDisplayPixelsWide(displayID)),
height: Int(CGDisplayPixelsHigh(displayID)),
refreshRate: DisplayModeSwitcher.currentRefreshRate(of: displayID)
)
}
}
}
extension NSScreen {
var displayID: CGDirectDisplayID {
(deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
}
}

Powered by TurnKey Linux.