crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 1603627c3c4240c2d7c47bfa0f75292d2a1d847e
parent 75ad17c32d19a5f1d18e79d49c0b0c0424ef0767
Author: Michael Camilleri <[email protected]>
Date:   Sat,  6 Jun 2026 11:33:24 +0900

Clarify replay playback controls

This commit splits the replay playback state into a selected speed and
an active/paused flag, instead of encoding stopped playback as speed 0.
The right-hand control is now a shaped play/pause button, while the
left-hand side shows the history symbol when paused and turns into a
tappable speed pill while playback is running. Pausing, including by
manual scrubbing, preserves the selected speed so resume continues at
the same pace.

The obsolete replay arrow contrast tuning is removed, while the the
debug tuning focused on step intervals is kept. The observable is
renamed from ReplayController to ReplayControls and moved into Models so
it no longer reads like an MVC controller living in Views.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 20++++++++++++--------
MCrossmate/CrossmateApp.swift | 2+-
ACrossmate/Models/ReplayControls.swift | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 6+++---
MCrossmate/Views/PuzzleView.swift | 2+-
DCrossmate/Views/ReplayController.swift | 191-------------------------------------------------------------------------------
MCrossmate/Views/SettingsView.swift | 26++------------------------
MCrossmate/Views/SuccessPanel.swift | 92+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
DTests/Unit/ReplayControllerTests.swift | 92-------------------------------------------------------------------------------
ATests/Unit/ReplayControlsTests.swift | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 383 insertions(+), 355 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; }; 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */; }; 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; }; + 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */; }; 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; }; @@ -24,7 +25,6 @@ 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; }; 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; - 20DC2123F5D488A258409AC4 /* ReplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D53FA98B65B381FF09EB02F9 /* ReplayController.swift */; }; 262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; }; 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; }; 2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */; }; @@ -92,7 +92,6 @@ 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */; }; 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; - 9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; }; @@ -116,6 +115,7 @@ C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C511387D9FFBCC2E2F5EF699 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 51318FC5DAE02D35CB005729 /* NotificationService.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; }; @@ -138,6 +138,7 @@ DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */; }; E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F86F0F1883A93F9622FB67 /* CloudZones.swift */; }; + E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */; }; E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; }; E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; @@ -191,6 +192,7 @@ 0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; + 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMonitorTests.swift; sourceTree = "<group>"; }; 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; }; 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; }; 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; @@ -230,6 +232,7 @@ 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; + 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControls.swift; sourceTree = "<group>"; }; 51318FC5DAE02D35CB005729 /* NotificationService.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; @@ -258,6 +261,7 @@ 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; 800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; }; + 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; @@ -277,7 +281,6 @@ 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; - A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControllerTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; @@ -312,7 +315,6 @@ CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; }; D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; - D53FA98B65B381FF09EB02F9 /* ReplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayController.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; @@ -416,8 +418,9 @@ 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */, - A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */, + 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */, 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */, + 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); @@ -443,6 +446,7 @@ 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */, D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */, E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, + 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, B9031A1574C21866940F6A2C /* XD.swift */, EAC61E2582D94B1E6EC67136 /* XDFileType.swift */, @@ -512,7 +516,6 @@ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */, AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */, E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */, - D53FA98B65B381FF09EB02F9 /* ReplayController.swift */, 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */, B23A692318044351247606DF /* SuccessPanel.swift */, ); @@ -769,10 +772,11 @@ 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */, - 9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */, + C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */, AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */, 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, + 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */, @@ -861,7 +865,7 @@ D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */, D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, - 20DC2123F5D488A258409AC4 /* ReplayController.swift in Sources */, + E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */, 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */, B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -497,7 +497,7 @@ private struct PuzzleDisplayView: View { // Finished-game timelines are immutable (edit-lockout), // so a cached assembly is reused verbatim on re-entry — // this is what stops rapid nav from re-running the merge - // each time a fresh `ReplayController` calls in. + // each time a fresh `ReplayControls` instance asks for it. return await ReplayAssembler.memoised( cached: services.cachedReplayTimeline(gameID: gameID), onHit: { cached in diff --git a/Crossmate/Models/ReplayControls.swift b/Crossmate/Models/ReplayControls.swift @@ -0,0 +1,189 @@ +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"] + } + + /// Default step interval (ms) per speed level, fastest last. + static let defaultSpeedMs = [350, 160, 70] + + /// 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] + } +} + +/// An immutable snapshot of the scrubber at one position — exactly what +/// `GridView` needs to render a rewound frame, decoupled from the controls' +/// mutable scrub/load state. A `nil` frame means "render the live grid". +struct ReplayFrame: Equatable { + /// Each touched cell's after-state at this position; cells absent here + /// render blank. + let cells: [GridPosition: JournalCellState] + /// The cell the most recent step changed — the playhead — or `nil` for a + /// batched gesture, which has no single focus to highlight. + let cursor: GridPosition? + /// Who made that move, so the playhead can take their colour. + let cursorAuthorID: String? +} + +/// View-model for the finish-banner replay scrubber. Loads a finished game's +/// merged journal (Phase 2b) once the banner appears, then exposes a scrub +/// position and the per-cell grid override that `GridView` renders while the +/// user drags back through history. +/// +/// Held as `@State` by `PuzzleView` so the position survives re-renders; it is +/// `.idle` (no override, live grid) until a solved game's banner triggers +/// `load`. +@MainActor +@Observable +final class ReplayControls { + enum Status: Equatable { + case idle + case loading + case ready(ReplayTimeline) + case waiting(missing: Int) + case unavailable + } + + private(set) var status: Status = .idle + + /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to + /// re-check after a `.waiting` result without polling. + private(set) var reloadToken = 0 + + /// Scrub position in `0...timeline.count`. Resting at `count` shows the + /// finished grid (override is `nil`, so the live `Game` renders); dragging + /// left rewinds. Set to `count` when a timeline loads so the head starts + /// hard-right at the end of the game. + var position: Int = 0 + + /// The number of replay speed steps offered by the speed control. + static let maxPlaybackSpeed = 3 + + /// The speed to use when playback starts or resumes. + var selectedPlaybackSpeed: Int = 1 + + /// Whether the replay is actively advancing. + var isPlaybackActive = false + + /// 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 isPlaybackActive else { return nil } + return .milliseconds(ReplayTuning.stepMilliseconds(forSpeed: selectedPlaybackSpeed)) + } + + /// Advances the playback speed one notch, wrapping back to the slowest + /// speed after the fastest. + func cycleSelectedPlaybackSpeed() { + selectedPlaybackSpeed = selectedPlaybackSpeed >= Self.maxPlaybackSpeed ? 1 : selectedPlaybackSpeed + 1 + } + + /// Toggles autoplay without losing the selected speed, so pause/resume + /// preserves the user's pace. + func togglePlayback() { + isPlaybackActive.toggle() + } + + /// Pauses autoplay — called when the user grabs the scrubber, so a manual + /// scrub always wins without changing the selected speed. + func pausePlayback() { + isPlaybackActive = false + } + + /// 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, isPlaybackActive else { return } + position = position >= timeline.count ? 0 : position + 1 + } + + var timeline: ReplayTimeline? { + if case .ready(let timeline) = status { return timeline } + return nil + } + + /// A scrubber is offered only for a ready, non-empty timeline. + var isScrubbable: Bool { + (timeline?.count ?? 0) > 0 + } + + /// The grid to render at the current position, or `nil` to show the live + /// finished grid (head at the far right, or nothing loaded). Recomputed per + /// scrub tick — `state(through:)` is O(position), trivial for one game. + var gridOverride: [GridPosition: JournalCellState]? { + guard let timeline, position < timeline.count else { return nil } + return timeline.state(through: position) + } + + /// The cell changed by the most recently applied step — the replay + /// playhead, highlighted so the eye can follow the rewind. Tracks + /// `gridOverride`: `nil` at rest (head at the far right, live grid shown), + /// non-nil only while actively scrubbed back. + var cursor: GridPosition? { + guard let timeline, position > 0, position < timeline.count else { return nil } + return timeline.focus(ofStep: position - 1) + } + + /// The author whose move the playhead highlights, used to tint it in that + /// author's colour so the rewind reads as each player's moves in turn. + /// Tracks `cursor`: `nil` whenever there is no playhead. + var cursorAuthorID: String? { + guard let timeline, position > 0, position < timeline.count else { return nil } + return timeline.actingAuthor(ofStep: position - 1) + } + + /// The frame `GridView` should render, bundling the grid override with the + /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at + /// rest (head hard-right), where the live finished grid shows instead. + var frame: ReplayFrame? { + guard let cells = gridOverride else { return nil } + return ReplayFrame(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID) + } + + /// Loads the replay via the caller's loader. Idempotent for an in-flight or + /// ready load; a `.waiting` / `.unavailable` result stays retryable, so + /// `retry()` (driven by the inbound-journal sync signal, or the manual + /// button) can re-check when a contributor's journal finally syncs. + func load(_ loader: () async -> JournalReplayResult) async { + switch status { + case .loading, .ready: + return + case .idle, .waiting, .unavailable: + break + } + status = .loading + let result = await loader() + switch result { + case .ready(let timeline): + status = .ready(timeline) + position = timeline.count + case .waiting(let missing): + status = .waiting(missing: missing) + case .unavailable: + status = .unavailable + } + } + + /// Drops back to `.idle` so the next `load` re-checks. Triggered by the + /// inbound-journal sync signal (a contributor's journal arrived) and by the + /// manual "Check again" affordance. + func retry() { + status = .idle + isPlaybackActive = false + selectedPlaybackSpeed = 1 + reloadToken += 1 + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -3120,9 +3120,9 @@ final class AppServices { /// Assembled replay timelines, keyed by game. A finished game's journals are /// frozen (edit-lockout), so its timeline never changes once built — caching - /// it here lets a `ReplayController` recreated by rapid finish-banner nav - /// re-entry skip re-running `ReplayAssembler.assemble`. Only `.ready` results - /// land here; `.waiting`/`.unavailable` stay retryable. + /// it here lets a `ReplayControls` instance recreated by rapid + /// finish-banner nav re-entry skip re-running `ReplayAssembler.assemble`. + /// Only `.ready` results land here; `.waiting`/`.unavailable` stay retryable. private var replayTimelineCache: [UUID: ReplayTimeline] = [:] /// A previously assembled timeline for `gameID`, if one was cached this diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -48,7 +48,7 @@ struct PuzzleView: View { @State private var destructiveActionError: String? @State private var isShowingShareSheet = false @State private var hasSolved = false - @State private var replay = ReplayController() + @State private var replay = ReplayControls() @State private var padLayout: PadLayout? @Environment(\.engagementStatus) private var engagementStatus diff --git a/Crossmate/Views/ReplayController.swift b/Crossmate/Views/ReplayController.swift @@ -1,191 +0,0 @@ -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". -struct ReplayFrame: Equatable { - /// Each touched cell's after-state at this position; cells absent here - /// render blank. - let cells: [GridPosition: JournalCellState] - /// The cell the most recent step changed — the playhead — or `nil` for a - /// batched gesture, which has no single focus to highlight. - let cursor: GridPosition? - /// Who made that move, so the playhead can take their colour. - let cursorAuthorID: String? -} - -/// View-model for the finish-banner replay scrubber. Loads a finished game's -/// merged journal (Phase 2b) once the banner appears, then exposes a scrub -/// position and the per-cell grid override that `GridView` renders while the -/// user drags back through history. -/// -/// Held as `@State` by `PuzzleView` so the position survives re-renders; it is -/// `.idle` (no override, live grid) until a solved game's banner triggers -/// `load`. -@MainActor -@Observable -final class ReplayController { - enum Status: Equatable { - case idle - case loading - case ready(ReplayTimeline) - case waiting(missing: Int) - case unavailable - } - - private(set) var status: Status = .idle - - /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to - /// re-check after a `.waiting` result without polling. - private(set) var reloadToken = 0 - - /// Scrub position in `0...timeline.count`. Resting at `count` shows the - /// finished grid (override is `nil`, so the live `Game` renders); dragging - /// left rewinds. Set to `count` when a timeline loads so the head starts - /// 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 - } - - /// A scrubber is offered only for a ready, non-empty timeline. - var isScrubbable: Bool { - (timeline?.count ?? 0) > 0 - } - - /// The grid to render at the current position, or `nil` to show the live - /// finished grid (head at the far right, or nothing loaded). Recomputed per - /// scrub tick — `state(through:)` is O(position), trivial for one game. - var gridOverride: [GridPosition: JournalCellState]? { - guard let timeline, position < timeline.count else { return nil } - return timeline.state(through: position) - } - - /// The cell changed by the most recently applied step — the replay - /// playhead, highlighted so the eye can follow the rewind. Tracks - /// `gridOverride`: `nil` at rest (head at the far right, live grid shown), - /// non-nil only while actively scrubbed back. - var cursor: GridPosition? { - guard let timeline, position > 0, position < timeline.count else { return nil } - return timeline.focus(ofStep: position - 1) - } - - /// The author whose move the playhead highlights, used to tint it in that - /// author's colour so the rewind reads as each player's moves in turn. - /// Tracks `cursor`: `nil` whenever there is no playhead. - var cursorAuthorID: String? { - guard let timeline, position > 0, position < timeline.count else { return nil } - return timeline.actingAuthor(ofStep: position - 1) - } - - /// The frame `GridView` should render, bundling the grid override with the - /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at - /// rest (head hard-right), where the live finished grid shows instead. - var frame: ReplayFrame? { - guard let cells = gridOverride else { return nil } - return ReplayFrame(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID) - } - - /// Loads the replay via the caller's loader. Idempotent for an in-flight or - /// ready load; a `.waiting` / `.unavailable` result stays retryable, so - /// `retry()` (driven by the inbound-journal sync signal, or the manual - /// button) can re-check when a contributor's journal finally syncs. - func load(_ loader: () async -> JournalReplayResult) async { - switch status { - case .loading, .ready: - return - case .idle, .waiting, .unavailable: - break - } - status = .loading - let result = await loader() - switch result { - case .ready(let timeline): - status = .ready(timeline) - position = timeline.count - case .waiting(let missing): - status = .waiting(missing: missing) - case .unavailable: - status = .unavailable - } - } - - /// Drops back to `.idle` so the next `load` re-checks. Triggered by the - /// inbound-journal sync signal (a contributor's journal arrived) and by the - /// manual "Check again" affordance. - func retry() { - status = .idle - playbackSpeed = 0 - reloadToken += 1 - } -} diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -144,8 +144,8 @@ 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. +/// `UserDefaults` keys `ReplayControls` reads. 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 { @@ -164,18 +164,9 @@ private struct ReplayTuningView: View { } 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) } } } @@ -208,17 +199,4 @@ private struct ReplayTuningView: View { } } } - - 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 @@ -4,7 +4,7 @@ struct SuccessPanel: View { let session: PlayerSession let roster: PlayerRoster /// Drives the finish-banner replay scrubber and the grid override above. - var replay: ReplayController? = nil + var replay: ReplayControls? = nil /// Loads the merged journal (Phase 2b). Called once when the banner appears /// (and again on "check again" while waiting on a peer's upload). var loadReplay: (() async -> JournalReplayResult)? = nil @@ -237,7 +237,7 @@ struct SuccessPanel: View { /// above through its move history. Disabled with a sync caption while a /// contributing device's journal is still missing (strict completeness). private struct ReplayScrubber: View { - @Bindable var replay: ReplayController + @Bindable var replay: ReplayControls /// The local player's accent colour, for the filled track. var fillColor: Color @@ -273,28 +273,26 @@ private struct ReplayScrubber: View { private func slider(count: Int) -> some View { HStack(spacing: 10) { - Image(systemName: "clock.arrow.circlepath") - .font(.footnote) - .foregroundStyle(.secondary) + historyOrSpeedControl 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() } + // user grabs the thumb, while preserving the selected speed. + onUserScrub: { replay.pausePlayback() } ) PlaybackControl( - speed: replay.playbackSpeed, - maxSpeed: ReplayController.maxPlaybackSpeed, - onTap: { replay.cyclePlaybackSpeed() } + isPlaying: replay.isPlaybackActive, + selectedSpeed: replay.selectedPlaybackSpeed, + onTap: { replay.togglePlayback() } ) } .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) { + // Drive autoplay: play/pause and speed changes restart the loop with + // the current interval. A paused replay yields a `nil` interval, so the + // task exits and the grid rests. + .task(id: "\(replay.isPlaybackActive)-\(replay.selectedPlaybackSpeed)") { guard let interval = replay.playbackStepInterval else { return } while !Task.isCancelled { try? await Task.sleep(for: interval) @@ -304,6 +302,40 @@ private struct ReplayScrubber: View { } } + private var historyOrSpeedControl: some View { + ZStack { + Image(systemName: "clock.arrow.circlepath") + .font(.footnote) + .foregroundStyle(.secondary) + .opacity(replay.isPlaybackActive ? 0 : 1) + .scaleEffect(replay.isPlaybackActive ? 0.75 : 1) + .blur(radius: replay.isPlaybackActive ? 2 : 0) + .accessibilityHidden(replay.isPlaybackActive) + + Button { + replay.cycleSelectedPlaybackSpeed() + } label: { + Text("\(replay.selectedPlaybackSpeed)x") + .font(.caption2.monospacedDigit().weight(.semibold)) + .contentTransition(.numericText()) + .frame(width: 28, height: 18) + .glassEffect(.regular.interactive(), in: .capsule) + .animation(.easeInOut(duration: 0.16), value: replay.selectedPlaybackSpeed) + } + .foregroundStyle(.secondary) + .buttonStyle(.plain) + .opacity(replay.isPlaybackActive ? 1 : 0) + .scaleEffect(replay.isPlaybackActive ? 1 : 1.35) + .blur(radius: replay.isPlaybackActive ? 0 : 2) + .allowsHitTesting(replay.isPlaybackActive) + .accessibilityHidden(!replay.isPlaybackActive) + .accessibilityLabel("Replay speed") + .accessibilityValue("Speed \(replay.selectedPlaybackSpeed) of \(ReplayControls.maxPlaybackSpeed)") + } + .frame(width: 30, height: 22) + .animation(.spring(response: 0.34, dampingFraction: 0.62), value: replay.isPlaybackActive) + } + private var waiting: some View { caption { Image(systemName: "arrow.triangle.2.circlepath") @@ -328,34 +360,24 @@ private struct ReplayScrubber: View { } } -/// 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. +/// A shaped play/pause control for replay autoplay. private struct PlaybackControl: View { - let speed: Int - let maxSpeed: Int + let isPlaying: Bool + let selectedSpeed: 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) + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.caption2.weight(.heavy)) + .frame(width: 26, height: 18) + .padding(.horizontal, 7) + .padding(.vertical, 4) .glassEffect(.regular.interactive(), in: .capsule) - .animation(.easeInOut(duration: 0.18), value: speed) + .animation(.easeInOut(duration: 0.18), value: isPlaying) } .buttonStyle(.plain) - .accessibilityLabel("Play replay") - .accessibilityValue(speed == 0 ? "Stopped" : "Speed \(speed) of \(maxSpeed)") + .accessibilityLabel(isPlaying ? "Pause replay" : "Play replay") + .accessibilityValue(isPlaying ? "Playing at speed \(selectedSpeed)" : "Paused") } } diff --git a/Tests/Unit/ReplayControllerTests.swift b/Tests/Unit/ReplayControllerTests.swift @@ -1,92 +0,0 @@ -import Foundation -import Testing - -@testable import Crossmate - -/// Derived scrub state on `ReplayController` — the glue between a loaded -/// `ReplayTimeline` and the grid override the finish banner renders. -@Suite("Replay controller") -@MainActor -struct ReplayControllerTests { - - private func entry(seq: Int64, at seconds: TimeInterval, row: Int, col: Int, letter: String) -> JournalValue { - JournalValue( - seq: seq, - timestamp: Date(timeIntervalSince1970: seconds), - position: GridPosition(row: row, col: col), - state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "a"), - actingAuthorID: "a", - kind: .input, - targetSeq: nil, - batchID: nil, - prevSeqAtCell: nil, - direction: nil - ) - } - - private func timeline() -> ReplayTimeline { - ReplayTimeline(merging: [[ - entry(seq: 0, at: 10, row: 0, col: 0, letter: "A"), - entry(seq: 1, at: 20, row: 0, col: 1, letter: "B"), - ]]) - } - - @Test("a ready load parks the head at the end with no override") - func readyParksAtEnd() async { - let controller = ReplayController() - let tl = timeline() - await controller.load { .ready(tl) } - - #expect(controller.isScrubbable) - #expect(controller.position == 2) // hard-right = finished grid - #expect(controller.gridOverride == nil) // at rest, render the live grid - #expect(controller.cursor == nil) - } - - @Test("scrubbing left reconstructs the grid and the playhead") - func scrubbingRewinds() async { - let controller = ReplayController() - let tl = timeline() - await controller.load { .ready(tl) } - - controller.position = 1 - #expect(controller.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A") - #expect(controller.gridOverride?[GridPosition(row: 0, col: 1)] == nil) - #expect(controller.cursor == GridPosition(row: 0, col: 0)) - - controller.position = 0 - #expect(controller.gridOverride?.isEmpty == true) - #expect(controller.cursor == nil) - } - - @Test("a waiting load is not scrubbable") - func waitingNotScrubbable() async { - let controller = ReplayController() - await controller.load { .waiting(missing: 2) } - - #expect(!controller.isScrubbable) - #expect(controller.gridOverride == nil) - if case .waiting(let missing) = controller.status { - #expect(missing == 2) - } else { - Issue.record("expected .waiting, got \(controller.status)") - } - } - - @Test("load is idempotent once ready, but retry re-opens it") - func retryReloads() async { - let controller = ReplayController() - await controller.load { .ready(timeline()) } - - // A second load while ready is a no-op (loader must not run). - await controller.load { - Issue.record("loader ran while already ready") - return .unavailable - } - #expect(controller.isScrubbable) - - controller.retry() - await controller.load { .waiting(missing: 1) } - #expect(!controller.isScrubbable) - } -} diff --git a/Tests/Unit/ReplayControlsTests.swift b/Tests/Unit/ReplayControlsTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Derived scrub state on `ReplayControls` — the glue between a loaded +/// `ReplayTimeline` and the grid override the finish banner renders. +@Suite("Replay controls") +@MainActor +struct ReplayControlsTests { + + private func entry(seq: Int64, at seconds: TimeInterval, row: Int, col: Int, letter: String) -> JournalValue { + JournalValue( + seq: seq, + timestamp: Date(timeIntervalSince1970: seconds), + position: GridPosition(row: row, col: col), + state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "a"), + actingAuthorID: "a", + kind: .input, + targetSeq: nil, + batchID: nil, + prevSeqAtCell: nil, + direction: nil + ) + } + + private func timeline() -> ReplayTimeline { + ReplayTimeline(merging: [[ + entry(seq: 0, at: 10, row: 0, col: 0, letter: "A"), + entry(seq: 1, at: 20, row: 0, col: 1, letter: "B"), + ]]) + } + + @Test("a ready load parks the head at the end with no override") + func readyParksAtEnd() async { + let controls = ReplayControls() + let tl = timeline() + await controls.load { .ready(tl) } + + #expect(controls.isScrubbable) + #expect(controls.position == 2) // hard-right = finished grid + #expect(controls.gridOverride == nil) // at rest, render the live grid + #expect(controls.cursor == nil) + } + + @Test("scrubbing left reconstructs the grid and the playhead") + func scrubbingRewinds() async { + let controls = ReplayControls() + let tl = timeline() + await controls.load { .ready(tl) } + + controls.position = 1 + #expect(controls.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(controls.gridOverride?[GridPosition(row: 0, col: 1)] == nil) + #expect(controls.cursor == GridPosition(row: 0, col: 0)) + + controls.position = 0 + #expect(controls.gridOverride?.isEmpty == true) + #expect(controls.cursor == nil) + } + + @Test("a waiting load is not scrubbable") + func waitingNotScrubbable() async { + let controls = ReplayControls() + await controls.load { .waiting(missing: 2) } + + #expect(!controls.isScrubbable) + #expect(controls.gridOverride == nil) + if case .waiting(let missing) = controls.status { + #expect(missing == 2) + } else { + Issue.record("expected .waiting, got \(controls.status)") + } + } + + @Test("load is idempotent once ready, but retry re-opens it") + func retryReloads() async { + let controls = ReplayControls() + await controls.load { .ready(timeline()) } + + // A second load while ready is a no-op (loader must not run). + await controls.load { + Issue.record("loader ran while already ready") + return .unavailable + } + #expect(controls.isScrubbable) + + controls.retry() + await controls.load { .waiting(missing: 1) } + #expect(!controls.isScrubbable) + } + + @Test("playback toggles without losing the selected speed") + func playbackTogglePreservesSelectedSpeed() async { + let controls = ReplayControls() + #expect(!controls.isPlaybackActive) + #expect(controls.selectedPlaybackSpeed == 1) + + controls.togglePlayback() + #expect(controls.isPlaybackActive) + + controls.cycleSelectedPlaybackSpeed() + controls.cycleSelectedPlaybackSpeed() + #expect(controls.selectedPlaybackSpeed == 3) + + controls.pausePlayback() + #expect(!controls.isPlaybackActive) + #expect(controls.selectedPlaybackSpeed == 3) + + controls.togglePlayback() + #expect(controls.isPlaybackActive) + #expect(controls.selectedPlaybackSpeed == 3) + + controls.retry() + #expect(!controls.isPlaybackActive) + #expect(controls.selectedPlaybackSpeed == 1) + } +}