Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -222,6 +253,7 @@ final class MatrixScreenSaverView: ScreenSaverView {
appendDebugLog("[\(debugIdentifier)] deinit")
animationActive = false
nativeRenderer.stop()
ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self))
releaseRenderingResources()
}

Expand Down
27 changes: 18 additions & 9 deletions Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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<ObjectIdentifier> = []
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
}
}
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading