Master-of-time controls: signed speed slider + chapter scrubber

The viewer is now in full control of time, like a film editor at a jog
wheel. Two new controls in the bottom Glass bar:

  - Speed slider, range −16…0…+16. Pull it left for reverse playback;
    sit at 0 to freeze. Speed change captures localTime at the OLD
    speed before applying the new one, so it doesn't retroactively
    rescale time already elapsed.
  - Chapter scrubber across the top of the bar. Drag to any point in
    the current chapter's continuous timeline (Ch01 ≈ 326 seconds at
    1×). Snaps to the resolved scene + offset under the playhead;
    isPlaying / speed are preserved across a scrub.

SceneEngine internals:

  - `localTime` is now clamped to [0, sceneDuration] so reverse play
    halts cleanly at scene boundaries (the auto-advance task picks up
    and crosses the boundary into the previous scene).
  - `startAutoAdvance` schedules the right side of the boundary based
    on the sign of speed. Crossing forward enters the next scene at
    t=0; crossing backward enters the previous scene at t=duration.
  - `setChapterPosition(_:)` resolves a chapter-global time into a
    (scene, localTime) pair and re-anchors. Used by the scrubber.
  - `currentChapterDuration` exposes the length the slider needs.

GlassControls rewrite:

  - Two-row layout: scrubber on top, transport + speed + dots + nav
    + settings on bottom.
  - The +/- discrete buttons are gone — speed is continuous.
  - Speed label color-codes direction (green forward / orange reverse
    / grey frozen) and shows ❚❚ 0× when stopped.
  - Wraps in a TimelineView so scrubber and label refresh at frame
    rate while paused or playing.

Bundled, harness still 55/55 invariants, 0 audit errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-05-06 22:50:11 +02:00
parent 7291645524
commit 715039bce2
2 changed files with 284 additions and 108 deletions

View file

@ -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..<currentChapter.sceneCount {
t += sceneDurationFor(SceneAddress(chapter: address.chapter, scene: s))
}
return t
}
/// Position within the current chapter's timeline (0..currentChapterDuration).
func chapterPosition(at date: Date) -> Double {
var t: Double = 0
for s in 0..<address.scene {
t += sceneDurationFor(SceneAddress(chapter: address.chapter, scene: s))
}
return t + localTime(at: date)
}
/// Seek to an arbitrary point in the current chapter's timeline.
/// Resolves which scene that point falls in and updates accumulatedLocal.
/// Preserves `isPlaying` and `speed`.
func setChapterPosition(_ position: Double) {
let clamped = max(0, min(currentChapterDuration, position))
var remaining = clamped
let chapter = address.chapter
for s in 0..<currentChapter.sceneCount {
let dur = sceneDurationFor(SceneAddress(chapter: chapter, scene: s))
if remaining <= dur || s == currentChapter.sceneCount - 1 {
let target = SceneAddress(chapter: chapter, scene: s)
currentGlobal = target.globalIndex
accumulatedLocal = remaining
playSessionStart = isPlaying ? Date().timeIntervalSinceReferenceDate : nil
stopAutoAdvance()
if isPlaying { startAutoAdvance() }
return
}
remaining -= dur
}
}
// MARK: - Navigation
@ -114,43 +166,46 @@ final class SceneEngine {
func togglePlay() {
if isPlaying {
// 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
let dur = sceneDurationFor(address)
accumulatedLocal = max(0, min(dur, accumulatedLocal))
}
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.
/// Set absolute speed. Captures whatever localTime the engine had at
/// the OLD speed so a speed change doesn't retroactively rescale the
/// time already elapsed.
func setSpeed(_ s: Double) {
let now = Date().timeIntervalSinceReferenceDate
if let start = playSessionStart {
let now = Date().timeIntervalSinceReferenceDate
accumulatedLocal += (now - start) * speed
let dur = sceneDurationFor(address)
accumulatedLocal = max(0, min(dur, accumulatedLocal))
playSessionStart = now
}
speed = max(0.25, min(4.0, speed + delta))
speed = max(Self.speedMin, min(Self.speedMax, s))
if isPlaying {
stopAutoAdvance()
startAutoAdvance()
}
}
func adjustSpeed(delta: Double) {
setSpeed(speed + delta)
}
// MARK: - Internals
/// 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
@ -159,32 +214,62 @@ final class SceneEngine {
private func currentLocalTimeNow() -> 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
// 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))
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
}
}

View file

@ -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)
}
}
}