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
31 changes: 19 additions & 12 deletions Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -91,6 +97,8 @@ extension Player {

incoming.play()
transitionTargetIndex = index
activeTransitionDurationSeconds = duration
isUserInitiatedSkipTransition = isUserInitiatedSkip
isTransitioning = true
}

Expand Down Expand Up @@ -130,13 +138,12 @@ extension Player {
incoming.volume = 1
incoming.vocalsGain = 1
incoming.bassGainDB = 0
// 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 {
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)
}
pushHistory(currentTrack)
audio.current = incoming
currentIndex = transitionTargetIndex
queueRefillAttempted = false // deck promotion changes the current track sans startCurrentFresh
Expand All @@ -147,9 +154,9 @@ extension Player {
currentPitchShiftSemitones = incomingPitchShiftSemitones
currentRate = incomingRate
isTransitioning = false
activeTransitionDurationSeconds = 0
isUserInitiatedSkipTransition = false
transitionProgress = 0
activeTransitionDuration = nil
transitionIsSkip = false
persistState()
notifyUpcoming()
}
Expand All @@ -168,8 +175,8 @@ extension Player {
audio.current.bassGainDB = 0
}
isTransitioning = false
activeTransitionDurationSeconds = 0
isUserInitiatedSkipTransition = false
transitionProgress = 0
activeTransitionDuration = nil
transitionIsSkip = false
}
}
40 changes: 26 additions & 14 deletions Packages/ContinuityKit/Sources/Playback/Player+Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,24 +105,36 @@ extension Player {

public func next() {
guard !queue.isEmpty, skipsRemaining > 0 else { return }
// 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
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)
pushHistory(currentTrack)
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
}
cancelTransition()
pushHistory(currentTrack)
currentIndex = target
startCurrentFresh()
if !isPlaying {
audio.current.play()
isPlaying = true
startTimer()
}
resumeAfterInterruption = false
beginTransition(
toIndex: targetIndex,
outgoingPosition: position,
duration: Player.skipTransitionDurationSeconds,
isUserInitiatedSkip: true
)
persistState()
}

Expand Down
37 changes: 22 additions & 15 deletions Packages/ContinuityKit/Sources/Playback/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -109,16 +115,6 @@ 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 @@ -173,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
Expand Down Expand Up @@ -241,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.
Expand Down Expand Up @@ -348,10 +345,11 @@ public final class Player {
persistState()
}
let dur = effectiveEndSeconds
let plan = TransitionPlan(curve: transitionSettings.curve,
duration: activeTransitionDuration ?? 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
Expand All @@ -373,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)
Expand Down
Loading