diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index cc971db..cdbaf03 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -130,9 +130,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) + // Natural end-of-track blend: earn one forward skip back (capped). A SKIP blend is a + // spend, not a completion — no earn, or the budget would never deplete. Both record + // the history step for unlimited previous-skips. + if !transitionIsSkip { + skipsRemaining = min(Player.maxSkips, skipsRemaining + 1) + } pushHistory(currentTrack) audio.current = incoming currentIndex = transitionTargetIndex @@ -145,6 +148,8 @@ extension Player { currentRate = incomingRate isTransitioning = false transitionProgress = 0 + activeTransitionDuration = nil + transitionIsSkip = false persistState() notifyUpcoming() } @@ -164,5 +169,7 @@ extension Player { } isTransitioning = false transitionProgress = 0 + activeTransitionDuration = nil + transitionIsSkip = false } } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index 892ee74..cf7c861 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -106,9 +106,22 @@ extension Player { public func next() { guard !queue.isEmpty, skipsRemaining > 0 else { return } skipsRemaining -= 1 + let target = (currentIndex + 1) % queue.count + // Smooth skip: while audibly playing, blend into the next track over a short fixed + // crossfade instead of a hard cut — the same dual-deck machinery as an end-of-track + // transition, just started now. finishTransition records the history step (and, + // because transitionIsSkip is set, does NOT earn the skip back — a skip is a spend). + // A second tap mid-blend, a paused player, or a one-track queue hard-cuts as before. + if isPlaying, !isTransitioning, queue.count > 1 { + activeTransitionDuration = Player.skipCrossfadeDuration + transitionIsSkip = true + beginTransition(toIndex: target, outgoingPosition: position) + persistState() + return + } cancelTransition() pushHistory(currentTrack) - currentIndex = (currentIndex + 1) % queue.count + currentIndex = target startCurrentFresh() persistState() } diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index ed8c1b2..e29c22c 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -109,6 +109,16 @@ public final class Player { var currentRate: Double = 1 /// Rate staged on the incoming deck; promoted to `currentRate` when the transition completes. var incomingRate: Double = 1 + /// Duration override for the IN-FLIGHT blend (skip blends use a fixed short fade regardless + /// of the user's crossfade setting). nil = use `transitionSettings.durationSeconds`. + /// Cleared when the transition finishes or cancels. + var activeTransitionDuration: TimeInterval? + /// True while the in-flight blend was started by a forward skip: its completion is a spend, + /// not a natural track finish — history is recorded but NO skip is earned back. + var transitionIsSkip = false + /// Fixed fade for skip blends — long enough to feel like a DJ mix-out, short enough to feel + /// like the skip "took". + static let skipCrossfadeDuration: TimeInterval = 5 /// True when playback was paused by an interruption/route change and should resume when the /// system says the coast is clear. @@ -338,7 +348,8 @@ public final class Player { persistState() } let dur = effectiveEndSeconds - let plan = TransitionPlan(curve: transitionSettings.curve, duration: transitionSettings.durationSeconds) + let plan = TransitionPlan(curve: transitionSettings.curve, + duration: activeTransitionDuration ?? transitionSettings.durationSeconds) if isTransitioning { // Drive the blend off the INCOMING deck's clock — it keeps advancing even after the