From 8b90602637c8957eccb51b8a4d78b7548dddbb49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 19:43:05 +0000 Subject: [PATCH] Make forward skips use five-second transitions Co-authored-by: sanylax0 --- .../Sources/Playback/Player+Transitions.swift | 28 ++++++++++++----- .../Sources/Playback/Player+Transport.swift | 31 +++++++++++++++++-- .../Sources/Playback/Player.swift | 26 +++++++++++++--- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index cc971db..6d88719 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -6,9 +6,15 @@ import os extension Player { /// Starts the incoming track on the idle deck at zero gain; `tick()` then ramps the blend. - func beginTransition(toIndex index: Int, outgoingPosition: Double) { - guard queue.indices.contains(index) else { return } - // Only reachable from tick() while playing, so the stack exists; funnel anyway. + func beginTransition( + toIndex index: Int, + outgoingPosition: Double, + duration: TimeInterval, + isUserInitiatedSkip: Bool + ) { + guard queue.indices.contains(index), duration > 0 else { return } + // Automatic blends arrive from tick(); skip blends may materialize a staged session first. + // Funnel both through the same lazy stack accessor. let audio = ensureAudioStack() let incoming = audio.idle incoming.load(queue[index]) @@ -91,6 +97,8 @@ extension Player { incoming.play() transitionTargetIndex = index + activeTransitionDurationSeconds = duration + isUserInitiatedSkipTransition = isUserInitiatedSkip isTransitioning = true } @@ -130,10 +138,12 @@ extension Player { incoming.volume = 1 incoming.vocalsGain = 1 incoming.bassGainDB = 0 - // The outgoing track played to its natural end — earn one forward skip back (capped), - // and remember it in the history for unlimited previous-skips. - skipsRemaining = min(Player.maxSkips, skipsRemaining + 1) - pushHistory(currentTrack) + if !isUserInitiatedSkipTransition { + // A scheduled blend means the outgoing track played naturally. Skip blends already + // spent their budget and recorded history when the button was pressed. + skipsRemaining = min(Player.maxSkips, skipsRemaining + 1) + pushHistory(currentTrack) + } audio.current = incoming currentIndex = transitionTargetIndex queueRefillAttempted = false // deck promotion changes the current track sans startCurrentFresh @@ -144,6 +154,8 @@ extension Player { currentPitchShiftSemitones = incomingPitchShiftSemitones currentRate = incomingRate isTransitioning = false + activeTransitionDurationSeconds = 0 + isUserInitiatedSkipTransition = false transitionProgress = 0 persistState() notifyUpcoming() @@ -163,6 +175,8 @@ extension Player { audio.current.bassGainDB = 0 } isTransitioning = false + activeTransitionDurationSeconds = 0 + isUserInitiatedSkipTransition = false transitionProgress = 0 } } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index 892ee74..613ff13 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -105,11 +105,36 @@ extension Player { public func next() { guard !queue.isEmpty, skipsRemaining > 0 else { return } - skipsRemaining -= 1 + // A second press during an explicit skip would only restart the same incoming track, + // spending another skip without advancing farther. + guard !isUserInitiatedSkipTransition else { return } + // If an automatic blend is already underway, restart it as a five-second user skip. cancelTransition() + skipsRemaining -= 1 pushHistory(currentTrack) - currentIndex = (currentIndex + 1) % queue.count - startCurrentFresh() + let targetIndex = (currentIndex + 1) % queue.count + + // Skipping from a paused/staged session still needs the outgoing deck materialized so + // there is audio to fade away. If CoreAudio cannot start, preserve the old hard-advance + // behavior rather than leaving the transport on the track the user skipped. + guard ensureCurrentLoaded(), let audio else { + currentIndex = targetIndex + startCurrentFresh() + persistState() + return + } + if !isPlaying { + audio.current.play() + isPlaying = true + startTimer() + } + resumeAfterInterruption = false + beginTransition( + toIndex: targetIndex, + outgoingPosition: position, + duration: Player.skipTransitionDurationSeconds, + isUserInitiatedSkip: true + ) persistState() } diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index ed8c1b2..8e2fe55 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -91,6 +91,12 @@ public final class Player { var baselineSeconds: TimeInterval = 0 /// Queue index the in-flight transition is moving to. var transitionTargetIndex = 0 + /// Duration captured when a blend starts. Skip blends are always five seconds; automatic + /// blends keep using the user's configured duration. + var activeTransitionDurationSeconds: TimeInterval = 0 + /// Skip blends spend their budget and record history when they start, so completion must not + /// award the natural-track-finish refund a scheduled blend receives. + var isUserInitiatedSkipTransition = false /// Seconds the incoming deck was seeked into its track for beat alignment — becomes the /// promoted deck's clock baseline when the transition completes. var incomingStartOffset: TimeInterval = 0 @@ -163,7 +169,7 @@ public final class Player { /// - play(tracks:) / next() / previous() / hardAdvance() → startCurrentFresh() /// - togglePlayPause() play direction (incl. remotePlay / lock screen) → ensureCurrentLoaded() /// - seek() with a live deck → ensureRunning() (paused pre-audio scrubs stay metadata-only) - /// - beginTransition() → called here directly (tick() only runs while playing, post-build) + /// - beginTransition() → automatic blends are post-build; next() loads the current deck first /// - recoverPlayback() / pauseForEnvironment() → observers are only registered here, so /// they cannot fire pre-build /// - handleDeleted() / prepare() / restore() / persistState() never build — they guard on @@ -231,6 +237,7 @@ public final class Player { /// Radio-style forward-skip budget: `next()` spends one; finishing a track naturally earns /// one back (capped). Previous-skips are unlimited and walk the persistent play history. static let maxSkips = 3 + static let skipTransitionDurationSeconds: TimeInterval = 5 public internal(set) var skipsRemaining = Player.maxSkips /// IDs of previously played tracks, most recent last. Persisted, so "previous" works across /// launches. @@ -338,9 +345,11 @@ public final class Player { persistState() } let dur = effectiveEndSeconds - let plan = TransitionPlan(curve: transitionSettings.curve, duration: transitionSettings.durationSeconds) - if isTransitioning { + let plan = TransitionPlan( + curve: transitionSettings.curve, + duration: activeTransitionDurationSeconds + ) // Drive the blend off the INCOMING deck's clock — it keeps advancing even after the // outgoing file drains, so the transition can never get stuck half-faded. let incomingElapsed = audio.idle.elapsed @@ -362,8 +371,17 @@ public final class Player { finishTransition() } } else if let next = nextIndex { + let plan = TransitionPlan( + curve: transitionSettings.curve, + duration: transitionSettings.durationSeconds + ) if plan.shouldStart(position: elapsed, trackDuration: dur, hasNextTrack: true) { - beginTransition(toIndex: next, outgoingPosition: elapsed) + beginTransition( + toIndex: next, + outgoingPosition: elapsed, + duration: transitionSettings.durationSeconds, + isUserInitiatedSkip: false + ) } else if dur > 0 && elapsed >= dur - 0.05 { // Reached the end without a crossfade (blend off, or track too short to blend) → hard cut. hardAdvance(toIndex: next)