Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -145,6 +148,8 @@ extension Player {
currentRate = incomingRate
isTransitioning = false
transitionProgress = 0
activeTransitionDuration = nil
transitionIsSkip = false
persistState()
notifyUpcoming()
}
Expand All @@ -164,5 +169,7 @@ extension Player {
}
isTransitioning = false
transitionProgress = 0
activeTransitionDuration = nil
transitionIsSkip = false
}
}
15 changes: 14 additions & 1 deletion Packages/ContinuityKit/Sources/Playback/Player+Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
13 changes: 12 additions & 1 deletion Packages/ContinuityKit/Sources/Playback/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading