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
5 changes: 5 additions & 0 deletions Packages/ContinuityKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,10 @@ let package = Package(
dependencies: ["Domain", .product(name: "ContinuityCore", package: "ContinuityCore")],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "PlaybackTests",
dependencies: ["Playback"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
]
)
16 changes: 14 additions & 2 deletions Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,7 @@ extension Player {
let wasPlaying = isPlaying
let resumePosition = position
// Don't touch the dead engine/decks — even stop() can throw on orphaned nodes.
isTransitioning = false
transitionProgress = 0
clearTransitionState()
isPlaying = false
stopTimer()
audio = nil
Expand Down Expand Up @@ -137,12 +136,25 @@ extension Player {
resumeAfterInterruption = false
// Observers only exist once the stack does, so `audio` is always live here.
guard currentTrack != nil, let audio else { return }
let transitionWasInterrupted = isTransitioning
guard ensureRunning() else {
if transitionWasInterrupted {
// The idle deck's schedule is no longer trustworthy. Clear the blend flags even
// when CoreAudio cannot restart so a later play cannot resume that stale deck.
clearTransitionState()
}
Logger.audio.error("engine restart failed during recovery")
isPlaying = false
stopTimer()
return
}
if transitionWasInterrupted {
// Configuration-change recovery bypasses pauseForEnvironment(), but an engine stop
// invalidates both decks just the same. Drop the incoming deck before rescheduling
// the outgoing one; otherwise pause/resume can call play() on its stale schedule.
Logger.audio.info("engine stop interrupted transition — discarding incoming deck")
cancelTransition()
}
// Engine stops invalidate node schedules — reschedule from where the clock stood.
if audio.current.seekRealFile(to: position) {
baselineSeconds = position
Expand Down
10 changes: 10 additions & 0 deletions Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,19 @@ extension Player {
audio.current.vocalsGain = 1
audio.current.bassGainDB = 0
}
clearTransitionState()
}

/// Clears blend bookkeeping without touching AVAudio objects. Media-services reset leaves
/// every node orphaned, so that path must abandon state without calling even `stop()`.
func clearTransitionState() {
isTransitioning = false
transitionTargetIndex = currentIndex
activeTransitionDurationSeconds = 0
isUserInitiatedSkipTransition = false
incomingStartOffset = 0
incomingPitchShiftSemitones = 0
incomingRate = 1
transitionProgress = 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import XCTest
@testable import Playback

@MainActor
final class PlayerTransitionRecoveryTests: XCTestCase {
func testClearingTransitionStateDoesNotMaterializeAudioStack() {
let player = Player()
player.currentIndex = 2
player.transitionTargetIndex = 3
player.activeTransitionDurationSeconds = 5
player.isUserInitiatedSkipTransition = true
player.incomingStartOffset = 1.5
player.incomingPitchShiftSemitones = 1
player.incomingRate = 1.05
player.transitionProgress = 0.4
player.isTransitioning = true

player.clearTransitionState()

XCTAssertFalse(player.isTransitioning)
XCTAssertEqual(player.transitionTargetIndex, player.currentIndex)
XCTAssertEqual(player.activeTransitionDurationSeconds, 0)
XCTAssertFalse(player.isUserInitiatedSkipTransition)
XCTAssertEqual(player.incomingStartOffset, 0)
XCTAssertEqual(player.incomingPitchShiftSemitones, 0)
XCTAssertEqual(player.incomingRate, 1)
XCTAssertEqual(player.transitionProgress, 0)
XCTAssertNil(player.audio)
}
}
Loading