diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index b052e48..efd2893 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -3,10 +3,16 @@ 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. +/// Time is FIRST-CLASS: +/// +/// - `isPlaying` toggles wall-clock advance. +/// - `speed` is SIGNED. Positive = forward, negative = reverse, +/// `0` = paused (regardless of `isPlaying`). Range −16…+16. +/// - `chapterPosition(at:)` and `setChapterPosition(_:)` give the user +/// a "master of time" handle: scrub freely to any point in the +/// current chapter's continuous timeline, in either direction. +/// - The app launches paused so the very first scene doesn't auto-scrub +/// past its opening beats before the viewer gets oriented. @MainActor @Observable final class SceneEngine { @@ -23,9 +29,15 @@ final class SceneEngine { /// survives system clock drift over the lifetime of one session. private var playSessionStart: Double? = nil + /// Signed playback speed. Negative values play in reverse; 0 freezes + /// time even if `isPlaying` is true. Clamped to [−16, +16]. var speed: Double = 1.0 let totalScenes: Int + /// Speed range exposed to the UI (signed, log-friendly). + static let speedMin: Double = -16 + static let speedMax: Double = 16 + /// Monotonic counter; incremented on every stopAutoAdvance() to invalidate in-flight asyncAfter blocks. private var advanceGeneration: Int = 0 let sceneDuration: Double = 8.0 @@ -34,15 +46,15 @@ final class SceneEngine { /// windows of one continuous serial timeline (`Ch01Timeline`), so each /// scene's duration is the duration of its window. The total Ch01 /// runtime at 1× ≈ 326 seconds — this is intentional pedagogical - /// slo-mo; speed it up with `adjustSpeed`. + /// slo-mo; speed it up with the speed slider. private static let durationOverrides: [SceneAddress: Double] = [ - SceneAddress(chapter: 1, scene: 0): 69.0, // Aaron writes α + sends to Ben - SceneAddress(chapter: 1, scene: 1): 38.0, // α to Carl - SceneAddress(chapter: 1, scene: 2): 67.5, // Ben writes β + sends to Aaron - SceneAddress(chapter: 1, scene: 3): 33.0, // Carl writes γ — asymmetry - SceneAddress(chapter: 1, scene: 4): 37.0, // γ to Aaron - SceneAddress(chapter: 1, scene: 5): 37.5, // β to Carl - SceneAddress(chapter: 1, scene: 6): 44.5, // γ to Ben + convergence + SceneAddress(chapter: 1, scene: 0): 69.0, + SceneAddress(chapter: 1, scene: 1): 38.0, + SceneAddress(chapter: 1, scene: 2): 67.5, + SceneAddress(chapter: 1, scene: 3): 33.0, + SceneAddress(chapter: 1, scene: 4): 37.0, + SceneAddress(chapter: 1, scene: 5): 37.5, + SceneAddress(chapter: 1, scene: 6): 44.5, ] /// Effective duration for the current scene, honoring overrides. @@ -59,27 +71,67 @@ final class SceneEngine { } /// 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. + /// state and signed speed. localTime is clamped to [0, sceneDuration]. func localTime(at date: Date) -> Double { guard let start = playSessionStart else { return accumulatedLocal } let live = (date.timeIntervalSinceReferenceDate - start) * speed - return max(0, accumulatedLocal + live) + let dur = sceneDurationFor(address) + return max(0, min(dur, accumulatedLocal + live)) } - /// Progress within current scene (0..1), capped at 1. Honors per-scene - /// duration overrides so a long scene's progress bar doesn't max out - /// after only 8 seconds. + /// Progress within current scene (0..1), capped at 1. func progress(at date: Date) -> Double { min(1.0, localTime(at: date) / sceneDurationFor(address)) } init() { self.totalScenes = AllChapters.totalScenes - // Launch paused at t=0. The user sees a still title frame and - // explicitly presses play (or the right arrow) to begin. + // Launch paused at t=0. + } + + // MARK: - Chapter-level position (the slider's territory) + + /// Total duration of the current chapter's timeline at 1× speed — + /// the sum of all its scenes' durations. + var currentChapterDuration: Double { + var t: Double = 0 + for s in 0.. Double { + var t: Double = 0 + for s in 0.. Double { guard let start = playSessionStart else { return accumulatedLocal } let live = (Date().timeIntervalSinceReferenceDate - start) * speed - return max(0, accumulatedLocal + live) + let dur = sceneDurationFor(address) + return max(0, min(dur, accumulatedLocal + live)) } + /// Schedule a one-shot task that fires when localTime hits the next + /// scene boundary in the current direction of travel: + /// + /// - speed > 0: fires when localTime reaches sceneDuration (advance) + /// - speed < 0: fires when localTime reaches 0 (retreat) + /// - speed == 0: never fires (frozen) + /// + /// Crossing a boundary jumps to the neighboring scene and re-anchors + /// localTime at the appropriate end. private func startAutoAdvance() { advanceGeneration += 1 let myGen = advanceGeneration - // 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)) + let dur = sceneDurationFor(address) + let now = currentLocalTimeNow() + + let interval: Double + if speed > 0.001 { + interval = max(0.05, (dur - now) / speed) + } else if speed < -0.001 { + interval = max(0.05, now / -speed) + } else { + return // 0 speed → frozen, no advance + } + 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(playing: true) - self.startAutoAdvance() - } else { - self.isPlaying = false - self.playSessionStart = nil + if self.speed > 0.001 { + if self.currentGlobal < self.totalScenes - 1 { + self.currentGlobal += 1 + self.resetSceneTime(playing: true) + self.startAutoAdvance() + } else { + self.isPlaying = false + self.playSessionStart = nil + } + } else if self.speed < -0.001 { + if self.currentGlobal > 0 { + self.currentGlobal -= 1 + let prevDur = self.sceneDurationFor(self.address) + self.accumulatedLocal = prevDur + self.playSessionStart = Date().timeIntervalSinceReferenceDate + self.startAutoAdvance() + } else { + self.isPlaying = false + self.playSessionStart = nil + } } } } private func stopAutoAdvance() { - // Invalidate any pending asyncAfter blocks. advanceGeneration += 1 } } diff --git a/CrisisViz/Sources/CrisisViz/Glass/GlassControls.swift b/CrisisViz/Sources/CrisisViz/Glass/GlassControls.swift index 4cca058..2a0f13f 100644 --- a/CrisisViz/Sources/CrisisViz/Glass/GlassControls.swift +++ b/CrisisViz/Sources/CrisisViz/Glass/GlassControls.swift @@ -1,57 +1,162 @@ import SwiftUI -/// Liquid Glass bottom control bar — play/pause, navigation, speed, progress dots. +/// Liquid Glass bottom control bar — a film-editor-style transport with +/// signed speed (reverse playback) and a chapter-position scrubber. +/// +/// The viewer is the master of time: +/// - Speed slider: −16× … 0 (frozen) … +16×. Pull it left for reverse. +/// - Position scrubber: drag freely along the chapter's continuous +/// timeline, in either direction. Snaps to scene starts on click. struct GlassControls: View { @Bindable var engine: SceneEngine @Environment(AppSettings.self) private var settings @State private var showSettings = false + /// Local mirror of the live chapter position so SwiftUI's Slider has + /// a binding it can drive without round-tripping through the engine + /// during a drag. Updated from the engine via `.task`/`.onReceive` + /// equivalents — here we just refresh on appear/each frame. + @State private var scrubPosition: Double = 0 + @State private var isScrubbing: Bool = false var body: some View { - GlassEffectContainer { - HStack(spacing: 16) { - // Navigation cluster - HStack(spacing: 4) { - navButton(icon: "backward.end.fill") { engine.goTo(global: 0) } - navButton(icon: "chevron.left") { engine.previous() } - playButton - navButton(icon: "chevron.right") { engine.next() } - navButton(icon: "forward.end.fill") { engine.goTo(global: engine.totalScenes - 1) } + TimelineView(.animation(minimumInterval: 1.0 / 30)) { timeline in + let livePosition = engine.chapterPosition(at: timeline.date) + GlassEffectContainer { + VStack(spacing: 6) { + // Top row: position scrubber across the chapter. + chapterScrubber(livePosition: livePosition) + + // Bottom row: play/pause + speed slider + chapter dots + settings. + HStack(spacing: 16) { + playButton + + speedControl + + Spacer() + + progressDots + + Spacer() + + navCluster + + settingsButton + } } - .glassEffect(.regular, in: .capsule) - - Spacer() - - // Progress dots - progressDots - .glassEffect(.regular, in: .capsule) - - Spacer() - - // Speed + counter + settings - HStack(spacing: 8) { - navButton(icon: "minus") { engine.adjustSpeed(delta: -0.25) } - Text(String(format: "%.1fx", engine.speed)) - .scaledFont(size: 11, weight: .bold, design: .monospaced) - .foregroundStyle(.secondary) - .frame(width: 36) - navButton(icon: "plus") { engine.adjustSpeed(delta: 0.25) } - - Text("\(engine.currentGlobal + 1)/\(engine.totalScenes)") - .scaledFont(size: 11, weight: .medium, design: .monospaced) - .foregroundStyle(.tertiary) - - settingsButton - } - .glassEffect(.regular, in: .capsule) + .padding(.horizontal, 18) + .padding(.vertical, 8) + } + .onAppear { scrubPosition = livePosition } + .onChange(of: livePosition) { _, new in + if !isScrubbing { scrubPosition = new } } - .padding(.horizontal, 20) - .padding(.vertical, 10) } } - /// Gear button → text-scale slider popover. Lives on the right side of - /// the control bar so it doesn't fight the play/transport cluster on the - /// left. + // MARK: - Chapter scrubber (top row) + + private func chapterScrubber(livePosition: Double) -> some View { + HStack(spacing: 10) { + Text(String(format: "%.1fs", scrubPosition)) + .scaledFont(size: 10, weight: .medium, design: .monospaced) + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .trailing) + + Slider( + value: Binding( + get: { scrubPosition }, + set: { newValue in + scrubPosition = newValue + engine.setChapterPosition(newValue) + } + ), + in: 0...max(0.01, engine.currentChapterDuration), + onEditingChanged: { editing in + isScrubbing = editing + if !editing { + // Sync any drift after release. + scrubPosition = engine.chapterPosition(at: Date()) + } + } + ) + .controlSize(.small) + .tint(.white.opacity(0.9)) + + Text(String(format: "%.0fs", engine.currentChapterDuration)) + .scaledFont(size: 10, weight: .medium, design: .monospaced) + .foregroundStyle(.secondary) + .frame(width: 48, alignment: .leading) + } + .padding(.horizontal, 4) + } + + // MARK: - Speed slider (signed) + + private var speedControl: some View { + HStack(spacing: 6) { + Image(systemName: "backward.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + + Slider( + value: Binding( + get: { engine.speed }, + set: { engine.setSpeed($0) } + ), + in: SceneEngine.speedMin...SceneEngine.speedMax + ) + .controlSize(.small) + .frame(width: 160) + .tint(speedTint) + + Image(systemName: "forward.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + + Text(speedLabel) + .scaledFont(size: 10, weight: .heavy, design: .monospaced) + .foregroundStyle(.primary) + .frame(width: 56, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .glassEffect(.regular, in: .capsule) + } + + private var speedTint: Color { + if engine.speed > 0.05 { return .green.opacity(0.8) } + if engine.speed < -0.05 { return .orange.opacity(0.8) } + return .gray.opacity(0.8) + } + + private var speedLabel: String { + if abs(engine.speed) < 0.05 { return "❚❚ 0×" } + return String(format: "%+.2f×", engine.speed) + } + + // MARK: - Cast clusters / play / dots / settings + + private var playButton: some View { + Button { + engine.togglePlay() + } label: { + Image(systemName: engine.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 13, weight: .bold)) + .frame(width: 32, height: 28) + } + .buttonStyle(.glass) + } + + private var navCluster: some View { + HStack(spacing: 4) { + navButton(icon: "backward.end.fill") { engine.goTo(global: 0) } + navButton(icon: "chevron.left") { engine.previous() } + navButton(icon: "chevron.right") { engine.next() } + navButton(icon: "forward.end.fill") { engine.goTo(global: engine.totalScenes - 1) } + } + .glassEffect(.regular, in: .capsule) + } + private var settingsButton: some View { Button { showSettings.toggle() @@ -69,22 +174,11 @@ struct GlassControls: View { } } - private var playButton: some View { - Button { - engine.togglePlay() - } label: { - Image(systemName: engine.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 14, weight: .bold)) - .frame(width: 36, height: 30) - } - .buttonStyle(.glass) - } - private func navButton(icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 11, weight: .bold)) - .frame(width: 28, height: 28) + .frame(width: 26, height: 26) } .buttonStyle(.glass) } @@ -95,20 +189,21 @@ struct GlassControls: View { let isCurrentChapter = chapter.id == engine.address.chapter let isPast = chapter.id < engine.address.chapter Circle() - .fill(isCurrentChapter ? Color.white : isPast ? Color.white.opacity(0.6) : Color.white.opacity(0.2)) - .frame(width: isCurrentChapter ? 8 : 5, height: isCurrentChapter ? 8 : 5) + .fill(isCurrentChapter ? Color.white + : isPast ? Color.white.opacity(0.6) + : Color.white.opacity(0.2)) + .frame(width: isCurrentChapter ? 8 : 5, + height: isCurrentChapter ? 8 : 5) } } .padding(.horizontal, 10) .padding(.vertical, 6) + .glassEffect(.regular, in: .capsule) } } // MARK: - Text-scale popover -/// Continuous slider for the global text-size multiplier. Bound to -/// `AppSettings.textScale`; every other view that uses `.scaledFont(...)` or -/// `settings.scaled(...)` reacts immediately. private struct TextScalePopover: View { @Bindable var settings: AppSettings @@ -139,12 +234,8 @@ private struct TextScalePopover: View { .buttonStyle(.bordered) .controlSize(.small) Spacer() - Text("A") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.tertiary) - Text("A") - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(.tertiary) + Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(.tertiary) + Text("A").font(.system(size: 18, weight: .bold)).foregroundStyle(.tertiary) } } }