From 463d77f0728ecf9278c2df5b5178186ecc73b87d Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Wed, 6 May 2026 20:46:36 +0200 Subject: [PATCH] SceneEngine: real pause + launch paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pause button was just a no-op for animation: localTime was always (now − sceneStart) × speed, so anything inside the scene kept moving even when isPlaying=false. The auto-advance task was the only thing the toggle actually controlled. Worse: every fresh launch from the Dock immediately started ticking, so the user opened the app and saw the title scene already several seconds in. Replace the "wall-clock anchor" with a two-state timer: - accumulatedLocal: time already watched in this scene - playSessionStart: wall-clock anchor for the live delta (nil when paused) localTime = accumulatedLocal + (now − sessionStart) × speed if playing = accumulatedLocal if paused Pause now folds the live delta into accumulatedLocal and clears the session anchor. Resume re-anchors. Scene navigation (next/previous/ goTo) zeros the accumulator and only opens a fresh session if the engine was already in the playing state. Speed changes capture the current localTime at the OLD speed so they don't retroactively rescale already-elapsed time. Auto-advance fires after the REMAINING duration (not the full duration) so a pause halfway through Ch1.3 doesn't restart its 24s clock when the user resumes. Co-Authored-By: Claude Opus 4.7 --- .../CrisisViz/Engine/SceneEngine.swift | 88 ++++++++++++++----- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index 698df74..7e21325 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -2,21 +2,33 @@ import SwiftUI /// Observable engine driving the immersive linear scene flow. /// Single source of truth for scene-local time. Chapters consume `localTime(at:)`. +/// +/// Pause is FIRST-CLASS: when `isPlaying` is false, `localTime(at:)` returns +/// the value it had when the user paused, frozen. Pressing play resumes from +/// where time stopped. The app launches paused so the very first scene +/// doesn't auto-scrub past its opening beats before the user gets oriented. @MainActor @Observable final class SceneEngine { private(set) var currentGlobal: Int = 0 private(set) var isPlaying: Bool = false - /// Wall-clock reference (Date.timeIntervalSinceReferenceDate) when the current scene started. - private(set) var sceneStartReference: Double = 0 + /// Local time accumulated in the current scene from prior play sessions + /// (i.e. the part of the scene the user has already watched). When the + /// user is paused, this is the full localTime. When playing, the live + /// delta from `playSessionStart` is added on top. + private var accumulatedLocal: Double = 0 + /// Wall-clock reference when the current play session started; nil when + /// paused. We anchor to `timeIntervalSinceReferenceDate` so the value + /// survives system clock drift over the lifetime of one session. + private var playSessionStart: Double? = nil var speed: Double = 1.0 let totalScenes: Int /// Monotonic counter; incremented on every stopAutoAdvance() to invalidate in-flight asyncAfter blocks. private var advanceGeneration: Int = 0 - let sceneDuration: Double = 8.0 // default at 1x + let sceneDuration: Double = 8.0 /// Per-(chapter,scene) duration overrides. Some scenes — notably the /// Ch01 scene-3 slow-motion gossip dramatization — can't compress into @@ -38,12 +50,15 @@ final class SceneEngine { AllChapters.list[address.chapter] } - /// Compute scene-local time at a given wall-clock date, scaled by speed. - /// Returns 0 if the scene hasn't actually started yet (e.g. fresh launch). + /// Compute scene-local time at a given wall-clock date, honoring pause + /// state. Speed scales the LIVE delta only — already-elapsed time is + /// preserved at whatever speed it was clocked at. func localTime(at date: Date) -> Double { - guard sceneStartReference > 0 else { return 0 } - let delta = date.timeIntervalSinceReferenceDate - sceneStartReference - return max(0, delta * speed) + guard let start = playSessionStart else { + return accumulatedLocal + } + let live = (date.timeIntervalSinceReferenceDate - start) * speed + return max(0, accumulatedLocal + live) } /// Progress within current scene (0..1), capped at 1. Honors per-scene @@ -55,7 +70,8 @@ final class SceneEngine { init() { self.totalScenes = AllChapters.totalScenes - self.sceneStartReference = Date().timeIntervalSinceReferenceDate + // Launch paused at t=0. The user sees a still title frame and + // explicitly presses play (or the right arrow) to begin. } // MARK: - Navigation @@ -65,7 +81,7 @@ final class SceneEngine { stopAutoAdvance() if currentGlobal < totalScenes - 1 { currentGlobal += 1 - resetSceneTime() + resetSceneTime(playing: wasPlaying) if wasPlaying { startAutoAdvance() } } } @@ -75,7 +91,7 @@ final class SceneEngine { stopAutoAdvance() if currentGlobal > 0 { currentGlobal -= 1 - resetSceneTime() + resetSceneTime(playing: wasPlaying) if wasPlaying { startAutoAdvance() } } } @@ -84,21 +100,37 @@ final class SceneEngine { let wasPlaying = isPlaying stopAutoAdvance() currentGlobal = max(0, min(global, totalScenes - 1)) - resetSceneTime() + resetSceneTime(playing: wasPlaying) if wasPlaying { startAutoAdvance() } } func togglePlay() { - isPlaying.toggle() if isPlaying { - resetSceneTime() - startAutoAdvance() - } else { + // Pausing: capture the live delta into the accumulator so the + // next localTime() call returns exactly the frozen value. + if let start = playSessionStart { + let now = Date().timeIntervalSinceReferenceDate + accumulatedLocal += (now - start) * speed + } + playSessionStart = nil + isPlaying = false stopAutoAdvance() + } else { + // Resuming: anchor a new live delta from the current accumulator. + playSessionStart = Date().timeIntervalSinceReferenceDate + isPlaying = true + startAutoAdvance() } } func adjustSpeed(delta: Double) { + // Capture current localTime at the OLD speed before changing speed, + // so the speed change doesn't retroactively scale already-elapsed time. + if let start = playSessionStart { + let now = Date().timeIntervalSinceReferenceDate + accumulatedLocal += (now - start) * speed + playSessionStart = now + } speed = max(0.25, min(4.0, speed + delta)) if isPlaying { stopAutoAdvance() @@ -108,25 +140,37 @@ final class SceneEngine { // MARK: - Internals - private func resetSceneTime() { - sceneStartReference = Date().timeIntervalSinceReferenceDate + /// Clear scene-local time. If the engine is currently in the playing + /// state, anchor a fresh play session from now; otherwise leave it nil + /// so the next localTime() call returns 0 cleanly. + private func resetSceneTime(playing: Bool) { + accumulatedLocal = 0 + playSessionStart = playing ? Date().timeIntervalSinceReferenceDate : nil + } + + private func currentLocalTimeNow() -> Double { + guard let start = playSessionStart else { return accumulatedLocal } + let live = (Date().timeIntervalSinceReferenceDate - start) * speed + return max(0, accumulatedLocal + live) } private func startAutoAdvance() { advanceGeneration += 1 let myGen = advanceGeneration - // Auto-advance honors the current scene's effective duration (longer - // scenes get more time). Speed scaling still applies. - let interval = sceneDurationFor(address) / max(0.1, speed) + // Auto-advance fires after the REMAINING time in this scene (so a + // pause→resume midway through Ch1.3 doesn't reset the 24s clock). + let remaining = sceneDurationFor(address) - currentLocalTimeNow() + let interval = max(0.05, remaining / max(0.1, speed)) Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(interval)) guard let self, self.advanceGeneration == myGen, self.isPlaying else { return } if self.currentGlobal < self.totalScenes - 1 { self.currentGlobal += 1 - self.resetSceneTime() + self.resetSceneTime(playing: true) self.startAutoAdvance() } else { self.isPlaying = false + self.playSessionStart = nil } } }