diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index cdbaf03..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,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 @@ -147,9 +154,9 @@ extension Player { currentPitchShiftSemitones = incomingPitchShiftSemitones currentRate = incomingRate isTransitioning = false + activeTransitionDurationSeconds = 0 + isUserInitiatedSkipTransition = false transitionProgress = 0 - activeTransitionDuration = nil - transitionIsSkip = false persistState() notifyUpcoming() } @@ -168,8 +175,8 @@ extension Player { audio.current.bassGainDB = 0 } isTransitioning = false + activeTransitionDurationSeconds = 0 + isUserInitiatedSkipTransition = 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 cf7c861..613ff13 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -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() } diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index e29c22c..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 @@ -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. @@ -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 @@ -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. @@ -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 @@ -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)