diff --git a/App/Sources/OutputController.swift b/App/Sources/OutputController.swift index 8b7b4b5..2b7778c 100644 --- a/App/Sources/OutputController.swift +++ b/App/Sources/OutputController.swift @@ -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()