diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index 61b458f..35132d0 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -40,6 +40,10 @@ final class MatrixScreenSaverView: ScreenSaverView { private var firstHiddenFrameTime: TimeInterval = 0 private static let hiddenFrameToleranceSeconds: TimeInterval = 5.0 + /// When `true`, the renderer start is deferred until the + /// ``ScreenSyncCoordinator`` resolves a shared scene start time. + private var pendingSync = false + private var windowRect = NSRect.zero private var titlebarRect = NSRect.zero private var terminalRect = NSRect.zero @@ -180,14 +184,27 @@ final class MatrixScreenSaverView: ScreenSaverView { updateScreenSaverLifecycleObservation() updateLayout() nativeRenderer.seedOffset = displaySeed - nativeRenderer.start() + + if !skipSyncDelay { + // Register with the coordinator and defer renderer start until + // all expected instances are ready (or the timeout elapses). + let screenCount = NSScreen.screens.count + ScreenSyncCoordinator.shared.register(ObjectIdentifier(self), expectedCount: screenCount) + pendingSync = true + NSLog("MatrixScreenSaver sync registered (%d/%d)", ScreenSyncCoordinator.shared.registeredCount, screenCount) + } else { + pendingSync = false + nativeRenderer.start() + } } /// Stops the renderer and releases temporary rendering resources. override func stopAnimation() { let wasActive = animationActive animationActive = false + pendingSync = false nativeRenderer.stop() + ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() if wasActive { NSLog("MatrixScreenSaver stopAnimation") @@ -211,6 +228,20 @@ final class MatrixScreenSaverView: ScreenSaverView { return } firstHiddenFrameTime = 0 + + // While waiting for multi-screen sync, poll the coordinator each + // frame. Once a shared start time is available, apply it and start + // the renderer so all displays begin simultaneously. + if pendingSync { + guard let startTime = ScreenSyncCoordinator.shared.resolvedStartTime() else { + return + } + pendingSync = false + nativeRenderer.overrideSceneStartTime = startTime + nativeRenderer.start() + NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", startTime) + } + super.animateOneFrame() if nativeRenderer.advance() { needsDisplay = true @@ -222,6 +253,7 @@ final class MatrixScreenSaverView: ScreenSaverView { appendDebugLog("[\(debugIdentifier)] deinit") animationActive = false nativeRenderer.stop() + ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() } diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index b666aad..140bcd3 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -239,10 +239,17 @@ final class NativeMatrixRenderer { private(set) var rows = 0 private(set) var levelColors = NativeMatrixRenderer.palette - /// XORed into sceneSeed in beginSceneSequence so each physical display - /// gets an independent animation while preserving per-display determinism. + /// XORed into the rain RNG seed so each physical display gets independent + /// rain animation while the scripted scenes (neo message, number intro) + /// remain identical across displays. var seedOffset: UInt64 = 0 + /// When set, ``beginSceneSequence()`` uses this value instead of computing + /// a start time from the current wall clock. The ``ScreenSyncCoordinator`` + /// sets this so every display begins its scene sequence at the exact same + /// moment. + var overrideSceneStartTime: TimeInterval? + private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool @@ -791,19 +798,21 @@ final class NativeMatrixRenderer { now = 100 frameIndex = 0 - // In preview contexts (System Settings thumbnail, preview host) skip the - // multi-screen sync delay so scenes begin within one second of launch. - // In the screensaver engine the 10-second boundary ensures all displays - // sharing the same activation window use an identical startTime and seed. + // When the ScreenSyncCoordinator provides a shared start time use it + // directly so every display begins at the exact same moment. Otherwise + // fall back to the legacy heuristics: skip the delay in preview contexts, + // or quantise to a 10-second window in ScreenSaverEngine. let nowTime = Date.timeIntervalSinceReferenceDate - if configuration.skipSyncDelay { + if let override = overrideSceneStartTime { + sceneStartTime = override + } else if configuration.skipSyncDelay { sceneStartTime = nowTime + 1.0 } else { let syncWindow: TimeInterval = 10.0 sceneStartTime = floor(nowTime / syncWindow) * syncWindow + syncWindow } - sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) ^ seedOffset - rainRNG = Xorshift64(seed: sceneSeed &+ 3) + sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) + rainRNG = Xorshift64(seed: sceneSeed &+ 3 &+ seedOffset) guard columns > 0, rows > 0 else { if configuration.neoMessageSceneEnabled { diff --git a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift new file mode 100644 index 0000000..70a7a47 --- /dev/null +++ b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Coordinates multi-screen startup so every screen saver instance begins +/// its scene sequence at the exact same moment. +/// +/// `ScreenSaverEngine` creates one `MatrixScreenSaverView` per attached +/// display. Without coordination, instances that start at different moments +/// may compute different scene start times, producing misaligned scenes. +/// +/// Each view calls ``register(_:expectedCount:)`` during `startAnimation()` +/// and polls ``resolvedStartTime()`` each frame. Once all expected instances +/// have registered — or the ``registrationTimeout`` elapses — every instance +/// receives the same start time and begins simultaneously. +final class ScreenSyncCoordinator { + static let shared = ScreenSyncCoordinator() + + /// Maximum seconds to wait for all instances before starting anyway. + static let registrationTimeout: TimeInterval = 20.0 + + /// Seconds after all instances register (or timeout) before scenes begin. + static let startDelay: TimeInterval = 1.0 + + private var expectedCount = 0 + private var registeredIDs: Set = [] + private var firstRegistrationTime: TimeInterval = 0 + private var resolvedTime: TimeInterval? + + /// The number of currently registered instances. + var registeredCount: Int { registeredIDs.count } + + /// The number of expected instances (set on first registration). + var expected: Int { expectedCount } + + /// Registers a screen saver instance and records the expected display count. + /// + /// Call once per instance from `startAnimation()`. On the first call of a + /// session the coordinator snapshots `expectedCount` and starts the timeout + /// clock. Subsequent calls add to the registered set. + /// + /// - Parameters: + /// - id: Unique identity of the registering view (use `ObjectIdentifier`). + /// - expectedCount: Total number of displays (typically `NSScreen.screens.count`). + func register(_ id: ObjectIdentifier, expectedCount: Int) { + if registeredIDs.isEmpty { + self.expectedCount = max(expectedCount, 1) + firstRegistrationTime = Date.timeIntervalSinceReferenceDate + resolvedTime = nil + } + registeredIDs.insert(id) + + if registeredIDs.count >= self.expectedCount && resolvedTime == nil { + resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay + } + } + + /// Removes an instance from the coordinator. + /// + /// When the last instance unregisters the coordinator resets, ready for + /// the next screen saver activation. + /// + /// - Parameter id: Unique identity of the unregistering view. + func unregister(_ id: ObjectIdentifier) { + registeredIDs.remove(id) + if registeredIDs.isEmpty { + resolvedTime = nil + expectedCount = 0 + } + } + + /// Returns the shared scene start time once all instances are ready, or + /// after ``registrationTimeout`` elapses. Returns `nil` while still + /// waiting for instances. + func resolvedStartTime() -> TimeInterval? { + if let time = resolvedTime { + return time + } + guard !registeredIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstRegistrationTime + if elapsed >= Self.registrationTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.startDelay + resolvedTime = time + return time + } + return nil + } +} diff --git a/build.sh b/build.sh index e6c6e30..d7830b5 100755 --- a/build.sh +++ b/build.sh @@ -38,6 +38,7 @@ swiftc \ "$SOURCE_DIR/NeoMessageScene.swift" \ "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ + "$SOURCE_DIR/ScreenSyncCoordinator.swift" \ "$SOURCE_DIR/MatrixScreenSaverView.swift" cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"