From 09b2335609a919fb968499607d0f89d048a8691b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:01:15 +0000 Subject: [PATCH] Blend previous-track skip like the forward skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previous() hard-cut into the earlier track via startCurrentFresh(), while next() runs a smooth 5s crossfade. Route the previous-track step through the same beginTransition(isUserInitiatedSkip:) path so stepping back blends (beatmatched/harmonic/vocal-aware) instead of cutting. Preserves every existing invariant: the >3s restart-current branch stays a hard restart, the history walk still consumes exactly one entry, and the forward-skip refund still happens exactly once (a user skip blend doesn't refund again on completion). Falls back to a hard cut when audio can't start, when under a second of outgoing audio remains, or when there is no distinct earlier track (single-track queue / history points at the current track) — a self-blend would just echo. Adds a guard so a second press during an in-flight skip is a no-op, mirroring next(), with a test pinning that it neither walks history twice nor double-refunds nor builds the audio stack. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016C9cyymf6W5k6SaGiYvdXN --- .../Sources/Playback/Player+Transport.swift | 53 +++++++++++++++++-- .../PlayerPreviousSkipTests.swift | 43 +++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 Packages/ContinuityKit/Tests/PlaybackTests/PlayerPreviousSkipTests.swift diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index 2eace88..f8018d5 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -152,6 +152,11 @@ extension Player { public func previous() { guard !queue.isEmpty else { return } + // A second press during an explicit skip would cancel the in-flight blend and walk the + // history a second time — dropping the entry it already consumed and double-refunding the + // skip. Ignore it until the blend lands, mirroring next(). + guard !isUserInitiatedSkipTransition else { return } + // If an automatic blend is already underway, restart it as a user skip-back. cancelTransition() // Restart the current track if we're more than 3s in; otherwise step back through the // play history (unlimited), falling back to the previous queue slot when history is empty @@ -169,11 +174,53 @@ extension Player { backIndex = queue.firstIndex { $0.id == historyIDs[historyIdx] } historyIDs.remove(at: historyIdx) } - currentIndex = backIndex ?? (currentIndex - 1 + queue.count) % queue.count + let targetIndex = backIndex ?? (currentIndex - 1 + queue.count) % queue.count // Stepping back a track refunds a forward skip (capped) — undoing a skip shouldn't - // leave the budget spent. Restart-current (above) deliberately doesn't refund. + // leave the budget spent. Restart-current (above) deliberately doesn't refund. A skip + // blend spends nothing on completion (finishTransition skips the refund for user skips), + // so this single refund holds whether we blend or hard-cut below. skipsRemaining = min(Player.maxSkips, skipsRemaining + 1) - startCurrentFresh() + + // No distinct earlier track to blend into (single-track queue, or the most recent history + // entry is the current track) — a self-blend would just echo the same audio, so restart + // instead, exactly as the >3s branch does. + guard targetIndex != currentIndex else { + startCurrentFresh() + persistState() + return + } + + // Blend back into the previous track exactly like next() blends forward: materialize the + // outgoing deck so there is audio to fade, then hand off on the idle deck. Skipping from a + // paused/staged session still needs the outgoing deck so there is something to fade away; + // if CoreAudio cannot start, hard-advance to the target rather than stranding the transport. + guard ensureCurrentLoaded(), let audio else { + currentIndex = targetIndex + startCurrentFresh() + persistState() + return + } + if !isPlaying { + audio.current.play() + isPlaying = true + startTimer() + } + resumeAfterInterruption = false + // Clamp the fade to the audio actually LEFT on the outgoing track (short tracks / hot + // endings), and hard-advance when under a second remains — too little to read as a blend. + let remaining = effectiveEndSeconds - position + guard remaining > 1 else { + currentIndex = targetIndex + startCurrentFresh() + persistState() + return + } + beginTransition( + toIndex: targetIndex, + outgoingPosition: position, + duration: min(Player.skipTransitionDurationSeconds, remaining), + isUserInitiatedSkip: true + ) persistState() } diff --git a/Packages/ContinuityKit/Tests/PlaybackTests/PlayerPreviousSkipTests.swift b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerPreviousSkipTests.swift new file mode 100644 index 0000000..c60e4e4 --- /dev/null +++ b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerPreviousSkipTests.swift @@ -0,0 +1,43 @@ +import XCTest +import Domain +@testable import Playback + +@MainActor +final class PlayerPreviousSkipTests: XCTestCase { + private func makeTrack(title: String, sortIndex: Int) -> Track { + Track( + title: title, + artist: "Test", + durationSeconds: 180, + gradientSeed: sortIndex, + sortIndex: sortIndex + ) + } + + /// A second previous() press while a user skip blend is still in flight must be a no-op: acting + /// on it would cancel the blend, walk the history a second time (dropping the entry the first + /// press already consumed), and double-refund the forward skip. It must also not materialize the + /// audio stack — the guard returns before any deck work, mirroring next(). + func testPreviousDuringUserSkipIsIgnored() { + let player = Player() + let a = makeTrack(title: "A", sortIndex: 0) + let b = makeTrack(title: "B", sortIndex: 1) + player.queue = [a, b] + player.currentIndex = 1 + player.historyIDs = [a.id] + player.skipsRemaining = 1 + player.transitionTargetIndex = 0 + player.isUserInitiatedSkipTransition = true + player.isTransitioning = true + + player.previous() + + // Nothing consumed, nothing refunded, no engine built, blend left intact. + XCTAssertEqual(player.historyIDs, [a.id]) + XCTAssertEqual(player.skipsRemaining, 1) + XCTAssertEqual(player.currentIndex, 1) + XCTAssertTrue(player.isTransitioning) + XCTAssertTrue(player.isUserInitiatedSkipTransition) + XCTAssertNil(player.audio) + } +}