SceneEngine: real pause + launch paused

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 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-06 20:46:36 +02:00
parent da3cd1d338
commit 463d77f072

View file

@ -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
// pauseresume 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
}
}
}