fix(video): outputs started twice when the 50 Hz switch posted a screen change

The mode switch posts didChangeScreenParameters synchronously; reconcile ran
nested before the output was registered and opened a second window/receiver,
which stayed visible after stopping. Register first, never nest reconcile,
handle the notification asynchronously. Switching sources keeps the 50 Hz mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
Samuel Zielke 2 days ago
parent 97c9905ae1
commit 5bc031d358

@ -21,7 +21,8 @@ final class ActiveOutput {
let receiver: NDIReceiver
let window: OutputWindow
let displayID: CGDirectDisplayID
let originalMode: CGDisplayMode?
/// 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?) {
@ -57,7 +58,8 @@ final class OutputController {
init() {
refreshScreens()
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.screensChanged() }
// 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() }
@ -98,8 +100,25 @@ final class OutputController {
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) })
@ -136,22 +155,32 @@ final class OutputController {
private func start(_ source: Source, on displayID: CGDirectDisplayID) {
guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return }
halt(displayID)
// 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
}
let mode = DisplayModeSwitcher.setRefreshRate(Self.targetRefreshRate, on: displayID)
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)
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()

Loading…
Cancel
Save

Powered by TurnKey Linux.