commit 947af7b7b38c00a1df0d756536f6755002b8ae21
parent bc197e6e676956b5f60e053485caa4e7a495f80a
Author: Michael Camilleri <[email protected]>
Date: Tue, 2 Jun 2026 21:43:08 +0900
Replay puzzles with a fast-forward control
The replay controls in the Success Panel offered a slider with a thumb.
This commit replaces it with a control that plays the replay back
automatically so a finished puzzle can be watched, not just dragged
through by hand.
A manual scrub always wins. CompactSlider gains an onUserScrub callback
fired from the coordinator's valueChanged — which UIKit raises only for
genuine user interaction, not the programmatic setValue autoplay uses to
follow the thumb — so grabbing the slider calls stopPlayback and the
control falls back to off.
The step intervals (350/160/70 ms) and the unlit-arrow opacity (0.3)
are read from UserDefaults via ReplayTuning, defaulting to those values
when unset, and exposed under a new Replay Tuning screen in the
Debugging menu so they can be dialled in on device without a rebuild.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 212 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Views/CompactSlider.swift b/Crossmate/Views/CompactSlider.swift
@@ -16,6 +16,10 @@ struct CompactSlider: UIViewRepresentable {
var fillColor: UIColor = .systemGray
/// Unfilled (maximum) track colour. Neutral so it reads behind any fill.
var trackColor: UIColor = .systemGray4
+ /// Called when the *user* moves the thumb. Programmatic `setValue` (e.g.
+ /// autoplay advancing the position) does not fire `.valueChanged`, so this
+ /// is exclusively a manual scrub — the cue to cancel autoplay.
+ var onUserScrub: (() -> Void)? = nil
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -119,6 +123,7 @@ struct CompactSlider: UIViewRepresentable {
init(_ parent: CompactSlider) { self.parent = parent }
@objc func valueChanged(_ slider: UISlider) {
+ parent.onUserScrub?()
let rounded = Int(slider.value.rounded())
if rounded != parent.value { parent.value = rounded }
}
diff --git a/Crossmate/Views/ReplayController.swift b/Crossmate/Views/ReplayController.swift
@@ -1,6 +1,36 @@
import Foundation
import Observation
+/// Tunable knobs for the finish-banner replay autoplay, persisted in
+/// `UserDefaults` and editable from the Debugging menu. Reads fall back to the
+/// shipping defaults whenever a key is unset, so an empty store behaves exactly
+/// as it did before these became adjustable.
+enum ReplayTuning {
+ enum Key {
+ static let speedMs = ["replay.speed1Ms", "replay.speed2Ms", "replay.speed3Ms"]
+ static let offOpacity = "replay.offOpacity"
+ }
+
+ /// Default step interval (ms) per speed level, fastest last.
+ static let defaultSpeedMs = [350, 160, 70]
+ /// Default contrast of an unlit ("off") fast-forward arrow.
+ static let defaultOffOpacity = 0.3
+
+ /// Step interval in milliseconds for `speed` (`1...defaultSpeedMs.count`),
+ /// from the store or the matching default when unset (stored `0`).
+ static func stepMilliseconds(forSpeed speed: Int) -> Int {
+ let index = speed - 1
+ guard defaultSpeedMs.indices.contains(index) else { return defaultSpeedMs.last ?? 0 }
+ let stored = UserDefaults.standard.integer(forKey: Key.speedMs[index])
+ return stored > 0 ? stored : defaultSpeedMs[index]
+ }
+
+ /// Contrast of an unlit arrow, from the store or the default when unset.
+ static var offOpacity: Double {
+ UserDefaults.standard.object(forKey: Key.offOpacity) as? Double ?? defaultOffOpacity
+ }
+}
+
/// An immutable snapshot of the scrubber at one position — exactly what
/// `GridView` needs to render a rewound frame, decoupled from the controller's
/// mutable scrub/load state. A `nil` frame means "render the live grid".
@@ -46,6 +76,43 @@ final class ReplayController {
/// hard-right at the end of the game.
var position: Int = 0
+ /// The number of speed steps the fast-forward control cycles through, and
+ /// the number of arrows it draws.
+ static let maxPlaybackSpeed = 3
+
+ /// Autoplay speed: `0` is stopped; `1...maxPlaybackSpeed` advance the
+ /// position automatically, faster at each step. The fast-forward control
+ /// cycles `0 → 1 → … → max → 0`, filling one more arrow per step, and the
+ /// user grabbing the scrubber resets it to `0`.
+ var playbackSpeed: Int = 0
+
+ /// Seconds between autoplay steps at the current speed, or `nil` when
+ /// stopped. Faster speeds step sooner; the per-speed intervals come from
+ /// `ReplayTuning` so they can be tuned live from the Debugging menu.
+ var playbackStepInterval: Duration? {
+ guard playbackSpeed > 0 else { return nil }
+ return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: playbackSpeed))
+ }
+
+ /// Advances the fast-forward control one notch, wrapping past the top back
+ /// to stopped.
+ func cyclePlaybackSpeed() {
+ playbackSpeed = (playbackSpeed + 1) % (Self.maxPlaybackSpeed + 1)
+ }
+
+ /// Stops autoplay — called when the user grabs the scrubber, so a manual
+ /// scrub always wins and the arrows fall back to off.
+ func stopPlayback() {
+ playbackSpeed = 0
+ }
+
+ /// Advances one autoplay step, looping back to the start once the head
+ /// reaches the end. A no-op when stopped or before a timeline loads.
+ func advancePlayback() {
+ guard let timeline, playbackSpeed > 0 else { return }
+ position = position >= timeline.count ? 0 : position + 1
+ }
+
var timeline: ReplayTimeline? {
if case .ready(let timeline) = status { return timeline }
return nil
@@ -118,6 +185,7 @@ final class ReplayController {
/// manual "Check again" affordance.
func retry() {
status = .idle
+ playbackSpeed = 0
reloadToken += 1
}
}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -47,6 +47,9 @@ struct SettingsView: View {
NavigationLink("Record Editor") {
RecordEditorView()
}
+ NavigationLink("Replay Tuning") {
+ ReplayTuningView()
+ }
Button("Reset Database", role: .destructive) {
showResetConfirmation = true
@@ -139,3 +142,83 @@ struct SettingsView: View {
}
}
}
+
+/// Live tuning for the finish-banner replay autoplay, backed by the same
+/// `UserDefaults` keys `ReplayController`/`PlaybackControl` read. Edits take
+/// effect on the next play, so the knobs can be dialled in without a rebuild.
+private struct ReplayTuningView: View {
+ var body: some View {
+ Form {
+ Section {
+ ForEach(Array(ReplayTuning.defaultSpeedMs.enumerated()), id: \.offset) { index, def in
+ IntervalSlider(
+ title: "Speed \(index + 1) step",
+ key: ReplayTuning.Key.speedMs[index],
+ defaultMs: def
+ )
+ }
+ } header: {
+ Text("Step Interval")
+ } footer: {
+ Text("Milliseconds between moves at each fast-forward speed. Lower is faster.")
+ }
+
+ Section {
+ OffOpacitySlider()
+ } header: {
+ Text("Arrow Contrast")
+ } footer: {
+ Text("Opacity of an unlit (off) arrow.")
+ }
+
+ Section {
+ Button("Reset to Defaults", role: .destructive) {
+ let defaults = UserDefaults.standard
+ for key in ReplayTuning.Key.speedMs { defaults.removeObject(forKey: key) }
+ defaults.removeObject(forKey: ReplayTuning.Key.offOpacity)
+ }
+ }
+ }
+ .navigationTitle("Replay Tuning")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private struct IntervalSlider: View {
+ let title: String
+ @AppStorage private var milliseconds: Int
+
+ init(title: String, key: String, defaultMs: Int) {
+ self.title = title
+ _milliseconds = AppStorage(wrappedValue: defaultMs, key)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ LabeledContent(title) {
+ Text("\(milliseconds) ms").monospacedDigit()
+ }
+ Slider(
+ value: Binding(
+ get: { Double(milliseconds) },
+ set: { milliseconds = Int($0) }
+ ),
+ in: 20...600,
+ step: 5
+ )
+ }
+ }
+ }
+
+ private struct OffOpacitySlider: View {
+ @AppStorage(ReplayTuning.Key.offOpacity) private var opacity = ReplayTuning.defaultOffOpacity
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ LabeledContent("Off opacity") {
+ Text(opacity, format: .number.precision(.fractionLength(2))).monospacedDigit()
+ }
+ Slider(value: $opacity, in: 0...1, step: 0.05)
+ }
+ }
+ }
+}
diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift
@@ -272,13 +272,32 @@ private struct ReplayScrubber: View {
Image(systemName: "clock.arrow.circlepath")
.font(.footnote)
.foregroundStyle(.secondary)
- CompactSlider(value: $replay.position, range: 0...count, fillColor: UIColor(fillColor))
- Text("\(replay.position)/\(count)")
- .font(.caption2.monospacedDigit())
- .foregroundStyle(.secondary)
- .frame(minWidth: 48, alignment: .trailing)
+ CompactSlider(
+ value: $replay.position,
+ range: 0...count,
+ fillColor: UIColor(fillColor),
+ // A manual scrub always wins: cancel autoplay the moment the
+ // user grabs the thumb.
+ onUserScrub: { replay.stopPlayback() }
+ )
+ PlaybackControl(
+ speed: replay.playbackSpeed,
+ maxSpeed: ReplayController.maxPlaybackSpeed,
+ onTap: { replay.cyclePlaybackSpeed() }
+ )
}
.frame(height: Self.rowHeight)
+ // Drive autoplay: each speed change restarts the loop with that speed's
+ // interval; speed 0 yields a `nil` interval, so the task exits and the
+ // grid rests. Cancellation (speed change or banner teardown) stops it.
+ .task(id: replay.playbackSpeed) {
+ guard let interval = replay.playbackStepInterval else { return }
+ while !Task.isCancelled {
+ try? await Task.sleep(for: interval)
+ if Task.isCancelled { break }
+ replay.advancePlayback()
+ }
+ }
}
private var waiting: some View {
@@ -304,3 +323,35 @@ private struct ReplayScrubber: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+
+/// A fast-forward control: `maxSpeed` overlapping right-chevrons inside an
+/// ordinary Liquid Glass capsule, so it plainly reads as a button. At rest every
+/// arrow is faint ("off"); each tap lights one more arrow as the autoplay speed
+/// steps up, wrapping back to off past the top.
+private struct PlaybackControl: View {
+ let speed: Int
+ let maxSpeed: Int
+ let onTap: () -> Void
+
+ /// Contrast of an unlit ("off") arrow, tunable from the Debugging menu.
+ @AppStorage(ReplayTuning.Key.offOpacity) private var offOpacity = ReplayTuning.defaultOffOpacity
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: -2) {
+ ForEach(0..<maxSpeed, id: \.self) { index in
+ Image(systemName: "chevron.right")
+ .foregroundStyle(index < speed ? Color.primary : .secondary.opacity(offOpacity))
+ }
+ }
+ .font(.caption2.weight(.heavy))
+ .padding(.horizontal, 9)
+ .padding(.vertical, 4)
+ .glassEffect(.regular.interactive(), in: .capsule)
+ .animation(.easeInOut(duration: 0.18), value: speed)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Play replay")
+ .accessibilityValue(speed == 0 ? "Stopped" : "Speed \(speed) of \(maxSpeed)")
+ }
+}