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

157 lines
5.6 KiB

import AppKit
import MusubiCore
import NDIKit
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
let 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] = [:]
/// 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
MainActor.assumeIsolated { self?.screensChanged() }
}
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.updateSnapshots() }
}
}
func show(_ source: Source, on displayID: CGDirectDisplayID) {
guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return }
stop(displayID)
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)")
return
}
let mode = DisplayModeSwitcher.setRefreshRate(Self.targetRefreshRate, on: displayID)
window.onCancel = { [weak self] in self?.stop(displayID) }
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()
outputs[displayID] = ActiveOutput(source: source, receiver: receiver, window: window, displayID: displayID, originalMode: mode.original)
Log.ndi.info("Output \(source.ndiName, privacy: .public) → \(screen.localizedName, privacy: .public) @ \(mode.refreshRate) Hz")
updateSleepBlocker()
refreshScreens()
}
func stop(_ 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() {
let connected = Set(NSScreen.screens.map(\.displayID))
for displayID in outputs.keys where !connected.contains(displayID) {
Log.ndi.notice("Display \(displayID) disconnected, stopping its output")
stop(displayID)
}
// 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)
}
}
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.