From 44f7e19d86d0c7700c72f5616549755473e8ef7f Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:43:16 -0500 Subject: [PATCH 01/20] feat(sdk): NoOpAudioSessionManager + session-seam invariant test --- .../Player/AudioSessionManager.swift | 24 +++++++++++++ .../AudioSessionOwnershipTests.swift | 20 +++++++++++ .../SessionSeamInvariantTests.swift | 34 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift create mode 100644 Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift diff --git a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift b/Sources/PlayolaPlayer/Player/AudioSessionManager.swift index 48a384f..bbe6791 100644 --- a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift +++ b/Sources/PlayolaPlayer/Player/AudioSessionManager.swift @@ -17,6 +17,30 @@ protocol AudioSessionManaging { var isConfigured: Bool { get } } +/// Inert session manager used when the HOST APP owns the process-global +/// AVAudioSession (see `PlayolaAudioSessionOwnership.hostOwned`). +/// +/// Reports `isConfigured == true` so SDK guards that gate on configuration +/// become pass-throughs. HOST CONTRACT: the app must configure (.playback) +/// and activate the session before play()/resumeAfterInterruption(); if it +/// doesn't, engine.start() throws and surfaces through the SDK error path. +final class NoOpAudioSessionManager: AudioSessionManaging { + var isConfigured: Bool { true } + init() {} + func configureForPlayback() async throws {} + func activate() async throws {} + func deactivate() async throws {} +} + +/// Who owns the process-global AVAudioSession. +public enum PlayolaAudioSessionOwnership: Sendable, Equatable { + /// SDK configures/activates the session itself (legacy default). + case sdkOwned + /// Host app owns the session; the SDK never touches it and registers no + /// AVAudioSession observers. See host-mode contract in README. + case hostOwned +} + #if os(iOS) || os(tvOS) /// iOS/tvOS implementation using AVAudioSession class AudioSessionManager: AudioSessionManaging { diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift new file mode 100644 index 0000000..4c1fcf3 --- /dev/null +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -0,0 +1,20 @@ +// AudioSessionOwnershipTests.swift +// PlayolaPlayer + +import Testing + +@testable import PlayolaPlayer + +@MainActor +struct AudioSessionOwnershipTests { + @Test + func noOpManagerIsInertAndReportsConfigured() async throws { + let manager = NoOpAudioSessionManager() + #expect(manager.isConfigured == true) // SDK guards/asserts must pass in host mode + try await manager.configureForPlayback() + try await manager.activate() + try await manager.deactivate() + // Inert by construction: bodies are empty. The seam invariant test below + // guarantees no other SDK code can reach AVAudioSession around this manager. + } +} diff --git a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift new file mode 100644 index 0000000..c700e73 --- /dev/null +++ b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift @@ -0,0 +1,34 @@ +// SessionSeamInvariantTests.swift +// PlayolaPlayer +// +// Structural guard: the ONLY file in the SDK allowed to reference +// AVAudioSession.sharedInstance is AudioSessionManager.swift. This is what makes +// "swap the manager" equivalent to "SDK never touches the session". + +import Foundation +import Testing + +struct SessionSeamInvariantTests { + @Test + func onlyAudioSessionManagerTouchesSharedSession() throws { + let thisFile = URL(fileURLWithPath: #filePath) + let repoRoot = + thisFile // Tests/PlayolaPlayerTests/ -> repo root + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let sourcesDir = repoRoot.appendingPathComponent("Sources") + let enumerator = FileManager.default.enumerator( + at: sourcesDir, includingPropertiesForKeys: nil)! + var offenders: [String] = [] + for case let url as URL in enumerator where url.pathExtension == "swift" { + guard url.lastPathComponent != "AudioSessionManager.swift" else { continue } + let content = try String(contentsOf: url, encoding: .utf8) + // Direct access AND the two session-mutating verbs (catches alias/wrapper bypass). + for needle in ["AVAudioSession.sharedInstance", ".setCategory(", ".setActive("] { + if content.contains(needle) { + offenders.append("\(url.lastPathComponent): \(needle)") + } + } + } + #expect(offenders.isEmpty, "Direct session access outside the seam: \(offenders)") + } +} From e58095db8be4410f1e382a3cb4b3ccbf3310dac5 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:47:54 -0500 Subject: [PATCH 02/20] test(sdk): polish seam invariant test (review feedback) --- .../AudioSessionOwnershipTests.swift | 2 +- .../SessionSeamInvariantTests.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index 4c1fcf3..e0012ea 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -7,7 +7,7 @@ import Testing @MainActor struct AudioSessionOwnershipTests { - @Test + @Test("NoOp manager is inert and reports isConfigured = true in host mode") func noOpManagerIsInertAndReportsConfigured() async throws { let manager = NoOpAudioSessionManager() #expect(manager.isConfigured == true) // SDK guards/asserts must pass in host mode diff --git a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift index c700e73..c9bb6d9 100644 --- a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift +++ b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift @@ -9,20 +9,26 @@ import Foundation import Testing struct SessionSeamInvariantTests { - @Test + @Test("Only AudioSessionManager.swift references AVAudioSession session-mutating APIs") func onlyAudioSessionManagerTouchesSharedSession() throws { let thisFile = URL(fileURLWithPath: #filePath) let repoRoot = thisFile // Tests/PlayolaPlayerTests/ -> repo root .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() let sourcesDir = repoRoot.appendingPathComponent("Sources") - let enumerator = FileManager.default.enumerator( - at: sourcesDir, includingPropertiesForKeys: nil)! + guard + let enumerator = FileManager.default.enumerator( + at: sourcesDir, includingPropertiesForKeys: nil) + else { + Issue.record("Sources directory not found at \(sourcesDir.path)") + return + } var offenders: [String] = [] for case let url as URL in enumerator where url.pathExtension == "swift" { guard url.lastPathComponent != "AudioSessionManager.swift" else { continue } let content = try String(contentsOf: url, encoding: .utf8) // Direct access AND the two session-mutating verbs (catches alias/wrapper bypass). + // Substring match: a comment mentioning ".setCategory(" would false-positive — acceptable; the failure message names the file so triage is trivial. for needle in ["AVAudioSession.sharedInstance", ".setCategory(", ".setActive("] { if content.contains(needle) { offenders.append("\(url.lastPathComponent): \(needle)") From 68dd6ae59084b72f64569064884be35f4fef5450 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:50:01 -0500 Subject: [PATCH 03/20] feat(sdk): protocol-typed session manager; guarded applyOwnership --- .../Player/PlayolaMainMixer.swift | 44 +++++++++++++++++-- .../AudioSessionOwnershipTests.swift | 20 +++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 8386e2f..5fe4edc 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -40,14 +40,16 @@ open class PlayolaMainMixer: NSObject { open var delegate: PlayolaMainMixerDelegate? private let errorReporter = PlayolaErrorReporter.shared - let audioSessionManager: AudioSessionManager + private(set) var audioSessionManager: any AudioSessionManaging + private var appliedOwnership: PlayolaAudioSessionOwnership? + private var sessionTouched = false private static let logger = OSLog(subsystem: "fm.playola.playolaCore", category: "MainMixer") - override init() { + init(audioSessionManager: (any AudioSessionManaging)? = nil) { self.mixerNode = AVAudioMixerNode() self.engine = AVAudioEngine() - self.audioSessionManager = AudioSessionManager() + self.audioSessionManager = audioSessionManager ?? AudioSessionManager() super.init() self.engine.attach(self.mixerNode) @@ -72,6 +74,7 @@ open class PlayolaMainMixer: NSObject { /// Configures the shared audio session for playback (fire-and-forget) public func configureAudioSession() { + sessionTouched = true guard !audioSessionManager.isConfigured else { return } Task { @MainActor in @@ -95,6 +98,7 @@ open class PlayolaMainMixer: NSObject { /// Use this before engine.start() to avoid stalling the audio engine. @MainActor public func ensureAudioSessionConfigured() async throws { + sessionTouched = true guard !audioSessionManager.isConfigured else { return } do { @@ -115,6 +119,7 @@ open class PlayolaMainMixer: NSObject { /// Deactivates the audio session when it's no longer needed public func deactivateAudioSession() { + sessionTouched = true guard audioSessionManager.isConfigured else { return } Task { @MainActor in @@ -133,6 +138,39 @@ open class PlayolaMainMixer: NSObject { self.delegate?.player(self, didPlayBuffer: buffer) } + /// Applies the ownership choice made in PlayolaStationPlayer.configure. + /// Idempotent for repeated same-value calls. Programmer errors fail loudly in + /// debug (assertionFailure) and are ignored in release: + /// - conflicting values, + /// - any call after the engine has started, + /// - switching to .hostOwned after the SDK already touched the session + /// (e.g. a SpinPlayer was constructed before configure — SpinPlayer.init + /// calls configureAudioSession()). + @MainActor + func applyOwnership(_ ownership: PlayolaAudioSessionOwnership) { + if let applied = appliedOwnership { + if applied != ownership { + assertionFailure("Conflicting audio-session ownership: \(applied) -> \(ownership)") + } + return + } + guard !engine.isRunning else { + assertionFailure("applyOwnership must be called before the engine starts") + return + } + if ownership == .hostOwned && sessionTouched { + assertionFailure( + "applyOwnership(.hostOwned) after the SDK already configured the session — " + + "call configure(audioSessionOwnership:) before creating any playback objects") + return + } + appliedOwnership = ownership + switch ownership { + case .sdkOwned: break // keep the AudioSessionManager from init + case .hostOwned: audioSessionManager = NoOpAudioSessionManager() + } + } + @MainActor public static let shared: PlayolaMainMixer = PlayolaMainMixer() } diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index e0012ea..8c5abfa 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -17,4 +17,24 @@ struct AudioSessionOwnershipTests { // Inert by construction: bodies are empty. The seam invariant test below // guarantees no other SDK code can reach AVAudioSession around this manager. } + + @Test("Mixer accepts an injected session manager") + func mixerAcceptsInjectedManager() { + let mixer = PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager()) + #expect(mixer.audioSessionManager is NoOpAudioSessionManager) + } + + @Test("applyOwnership is idempotent for repeated same-value calls") + func applyOwnershipIsIdempotentForSameValue() { + let mixer = PlayolaMainMixer() // fresh instance — never .shared in tests + mixer.applyOwnership(.hostOwned) + mixer.applyOwnership(.hostOwned) // must not trap + #expect(mixer.audioSessionManager is NoOpAudioSessionManager) + } + + @Test("Default manager is the real AudioSessionManager (sdkOwned)") + func applyOwnershipDefaultsToSdkOwned() { + let mixer = PlayolaMainMixer() + #expect(mixer.audioSessionManager is AudioSessionManager) + } } From 434eef7da337eef9f28b2744d028557d62f1a087 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:52:03 -0500 Subject: [PATCH 04/20] docs(sdk): document sessionTouched tripwire field --- Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 5fe4edc..5068d80 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -42,6 +42,8 @@ open class PlayolaMainMixer: NSObject { private let errorReporter = PlayolaErrorReporter.shared private(set) var audioSessionManager: any AudioSessionManaging private var appliedOwnership: PlayolaAudioSessionOwnership? + /// True once any session-touching call has run (configure/ensure/deactivate). + /// Used to fail loudly when ownership arrives too late. private var sessionTouched = false private static let logger = OSLog(subsystem: "fm.playola.playolaCore", category: "MainMixer") From 05d4710361575ca318119127f3289d880a10ec28 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:55:33 -0500 Subject: [PATCH 05/20] fix(sdk): main-actor isolate session-touching mixer methods (review) --- .../PlayolaPlayer/Player/PlayolaMainMixer.swift | 2 ++ .../AudioSessionOwnershipTests.swift | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 5068d80..073adc6 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -75,6 +75,7 @@ open class PlayolaMainMixer: NSObject { } /// Configures the shared audio session for playback (fire-and-forget) + @MainActor public func configureAudioSession() { sessionTouched = true guard !audioSessionManager.isConfigured else { return } @@ -120,6 +121,7 @@ open class PlayolaMainMixer: NSObject { } /// Deactivates the audio session when it's no longer needed + @MainActor public func deactivateAudioSession() { sessionTouched = true guard audioSessionManager.isConfigured else { return } diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index 8c5abfa..a94b95f 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -5,6 +5,14 @@ import Testing @testable import PlayolaPlayer +final class SpyAudioSessionManager: AudioSessionManaging { + var isConfigured: Bool { true } // simulates host mode / already-configured + private(set) var configureCount = 0 + func configureForPlayback() async throws { configureCount += 1 } + func activate() async throws {} + func deactivate() async throws {} +} + @MainActor struct AudioSessionOwnershipTests { @Test("NoOp manager is inert and reports isConfigured = true in host mode") @@ -37,4 +45,13 @@ struct AudioSessionOwnershipTests { let mixer = PlayolaMainMixer() #expect(mixer.audioSessionManager is AudioSessionManager) } + + @Test("configureAudioSession is a no-op when the manager reports configured") + func configureIsNoOpWhenManagerReportsConfigured() async { + let spy = SpyAudioSessionManager() + let mixer = PlayolaMainMixer(audioSessionManager: spy) + mixer.configureAudioSession() + await Task.yield() // let any (incorrectly) spawned Task run + #expect(spy.configureCount == 0) + } } From b2c356fb73b230978e7149e38fa7444945acc962 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 17:59:35 -0500 Subject: [PATCH 06/20] feat(sdk): audioSessionOwnership in configure(); host mode ignores session events --- .../Player/PlayolaStationPlayer.swift | 43 +++++++++++++++--- .../AudioSessionOwnershipTests.swift | 45 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index c922446..59682a1 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -68,6 +68,11 @@ final public class PlayolaStationPlayer: ObservableObject { private let errorReporter = PlayolaErrorReporter.shared private var authProvider: PlayolaAuthenticationProvider? private let urlSession: URLSessionProtocol + /// Injectable for tests; production uses .shared. + let mainMixer: PlayolaMainMixer + private var audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned + /// Whether the SDK reacts to AVAudioSession interruption/route events itself. + var handlesSessionEventsInternally: Bool { audioSessionOwnership == .sdkOwned } /// Base delay (seconds) for the initial schedule-fetch exponential backoff. /// Internal so tests can disable the wait; not part of the public API. @@ -122,11 +127,18 @@ final public class PlayolaStationPlayer: ObservableObject { /// - Parameters: /// - authProvider: Provider for JWT tokens /// - baseURL: Base URL for API endpoints. Defaults to production URL. + /// - audioSessionOwnership: Who owns AVAudioSession. Defaults to `.sdkOwned`. public func configure( authProvider: PlayolaAuthenticationProvider, - baseURL: URL = URL(string: "https://admin-api.playola.fm")! + baseURL: URL = URL(string: "https://admin-api.playola.fm")!, + audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned ) { self.authProvider = authProvider + self.audioSessionOwnership = audioSessionOwnership + mainMixer.applyOwnership(audioSessionOwnership) + #if os(iOS) || os(tvOS) + if audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } + #endif self.listeningSessionReporter = ListeningSessionReporter( stationPlayer: self, authProvider: authProvider, baseURL: baseURL) self.baseUrl = baseURL @@ -160,10 +172,12 @@ final public class PlayolaStationPlayer: ObservableObject { @MainActor internal init( fileDownloadManager: FileDownloadManaging? = nil, - urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session) + urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session), + mainMixer: PlayolaMainMixer? = nil ) { self.fileDownloadManager = fileDownloadManager ?? FileDownloadManagerAsync.shared self.urlSession = urlSession + self.mainMixer = mainMixer ?? .shared self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) @@ -186,7 +200,7 @@ final public class PlayolaStationPlayer: ObservableObject { self, selector: #selector(handleAudioEngineConfigurationChange(_:)), name: .AVAudioEngineConfigurationChange, - object: PlayolaMainMixer.shared.engine + object: self.mainMixer.engine ) #endif } @@ -760,6 +774,16 @@ final public class PlayolaStationPlayer: ObservableObject { return false } + // MARK: - Internal test seams + + func setStateForTesting(_ state: State, stationId: String?) { + self.state = state + self.stationId = stationId + } + + var isSuspendedForTesting: Bool { isSuspended } + var interruptedStationIdForTesting: String? { interruptedStationId } + /// Stops the current playback and releases associated resources. /// /// This method: @@ -815,7 +839,15 @@ final public class PlayolaStationPlayer: ObservableObject { } #if os(iOS) || os(tvOS) + private func removeAudioSessionObservers() { + NotificationCenter.default.removeObserver( + self, name: AVAudioSession.interruptionNotification, object: nil) + NotificationCenter.default.removeObserver( + self, name: AVAudioSession.routeChangeNotification, object: nil) + } + @objc public func handleAudioRouteChange(_ notification: Notification) { + guard handlesSessionEventsInternally else { return } // host owns policy guard let userInfo = notification.userInfo, let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) @@ -855,6 +887,7 @@ final public class PlayolaStationPlayer: ObservableObject { } @objc public func handleAudioSessionInterruption(_ notification: Notification) { + guard handlesSessionEventsInternally else { return } // host owns policy guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) @@ -915,8 +948,8 @@ final public class PlayolaStationPlayer: ObservableObject { Task { @MainActor in do { - try await PlayolaMainMixer.shared.audioSessionManager.activate() - try await PlayolaMainMixer.shared.restartEngine() + try await mainMixer.audioSessionManager.activate() + try await mainMixer.restartEngine() try await self.play(stationId: stationToResume) } catch { os_log( diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index a94b95f..b3f4b16 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -1,6 +1,7 @@ // AudioSessionOwnershipTests.swift // PlayolaPlayer +import AVFAudio import Testing @testable import PlayolaPlayer @@ -54,4 +55,48 @@ struct AudioSessionOwnershipTests { await Task.yield() // let any (incorrectly) spawned Task run #expect(spy.configureCount == 0) } + + // MARK: - PlayolaStationPlayer ownership wiring + + @Test("Host mode disables internal session-event handling; default keeps it") + func hostModeDisablesInternalSessionHandling() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + #expect(player.handlesSessionEventsInternally == false) + + let legacy = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + legacy.configure(authProvider: MockAuthProvider()) // default .sdkOwned + #expect(legacy.handlesSessionEventsInternally == true) + } + + #if os(iOS) || os(tvOS) + @Test("Host mode ignores interruption notifications") + func hostModeIgnoresInterruptionNotifications() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + player.schedulingTask = Task {} // sentinel: must survive the notification + + let note = Notification( + name: AVAudioSession.interruptionNotification, object: nil, + userInfo: [ + AVAudioSessionInterruptionTypeKey: + AVAudioSession.InterruptionType.began.rawValue + ]) + player.handleAudioSessionInterruption(note) + + #expect(player.isSuspendedForTesting == false) + #expect(player.interruptedStationIdForTesting == nil) + #expect(player.schedulingTask?.isCancelled == false) + } + #endif } From 667154bb1e8fd049f58d5db4eeef762216d752b9 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:05:17 -0500 Subject: [PATCH 07/20] fix(sdk): mirror mixer-applied ownership in configure (review) --- .../Player/PlayolaMainMixer.swift | 5 ++++- .../Player/PlayolaStationPlayer.swift | 9 ++++++-- .../AudioSessionOwnershipTests.swift | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index 073adc6..e97218b 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -41,7 +41,10 @@ open class PlayolaMainMixer: NSObject { open var delegate: PlayolaMainMixerDelegate? private let errorReporter = PlayolaErrorReporter.shared private(set) var audioSessionManager: any AudioSessionManaging - private var appliedOwnership: PlayolaAudioSessionOwnership? + /// The ownership actually latched by the first applyOwnership call. + /// Internal so PlayolaStationPlayer.configure can stay consistent with it + /// even when a conflicting re-configure is silently ignored in release. + private(set) var appliedOwnership: PlayolaAudioSessionOwnership? /// True once any session-touching call has run (configure/ensure/deactivate). /// Used to fail loudly when ownership arrives too late. private var sessionTouched = false diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 59682a1..957fec9 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -134,10 +134,13 @@ final public class PlayolaStationPlayer: ObservableObject { audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned ) { self.authProvider = authProvider - self.audioSessionOwnership = audioSessionOwnership mainMixer.applyOwnership(audioSessionOwnership) + // Mirror what the mixer actually latched — a conflicting re-configure is + // asserted in debug and silently ignored in release; mirroring keeps the + // notification-handler gate consistent with the real session manager. + self.audioSessionOwnership = mainMixer.appliedOwnership ?? audioSessionOwnership #if os(iOS) || os(tvOS) - if audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } + if self.audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } #endif self.listeningSessionReporter = ListeningSessionReporter( stationPlayer: self, authProvider: authProvider, baseURL: baseURL) @@ -839,6 +842,8 @@ final public class PlayolaStationPlayer: ObservableObject { } #if os(iOS) || os(tvOS) + // `object: nil` is intentional and name-scoped: it removes only these two + // named registrations, not the AVAudioEngineConfigurationChange observer. private func removeAudioSessionObservers() { NotificationCenter.default.removeObserver( self, name: AVAudioSession.interruptionNotification, object: nil) diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index b3f4b16..a098aa7 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -75,6 +75,28 @@ struct AudioSessionOwnershipTests { #expect(legacy.handlesSessionEventsInternally == true) } + @Test("Conflicting re-configure keeps player ownership consistent with mixer") + func conflictingReconfigureStaysConsistent() { + // Only the same-value re-configure path is exercised: a conflicting value + // would trip applyOwnership's assertionFailure in debug test builds (the + // conflict path stays a documented debug tripwire, like sessionTouched). + // configure mirrors mixer.appliedOwnership, so even a release-mode + // conflicting re-configure cannot reopen the notification-handler gate. + let mixer = PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager()) + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: mixer) + + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + #expect(player.handlesSessionEventsInternally == false) + #expect(mixer.appliedOwnership == .hostOwned) + + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + #expect(player.handlesSessionEventsInternally == false) + #expect(mixer.appliedOwnership == .hostOwned) + } + #if os(iOS) || os(tvOS) @Test("Host mode ignores interruption notifications") func hostModeIgnoresInterruptionNotifications() { From f389624496e4543fcb7492a7bb725c1f2d903591 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:08:36 -0500 Subject: [PATCH 08/20] feat(sdk): generation-safe pauseForInterruption + throwing resumeAfterInterruption --- .../Player/PlayolaStationPlayer.swift | 69 ++++++++++++++----- .../AudioSessionOwnershipTests.swift | 48 +++++++++++++ ...ayolaStationPlayerScheduleRetryTests.swift | 1 + 3 files changed, 99 insertions(+), 19 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 957fec9..5476123 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -155,6 +155,10 @@ final public class PlayolaStationPlayer: ObservableObject { /// fetch exhausted its retries). Lets consumers render a recoverable /// error instead of being stuck in a prior state. case error(StationPlayerError) + /// Playback suspended by an interruption (host- or legacy-driven). The spin + /// is the one that was playing at pause time — display metadata only; it + /// will be re-fetched and re-synced on resume. + case paused(Spin) } @Published public var state: PlayolaStationPlayer.State = .idle { @@ -841,6 +845,48 @@ final public class PlayolaStationPlayer: ObservableObject { os_log("✅ STOP completed", log: PlayolaStationPlayer.logger, type: .info) } + /// Host-driven interruption pause. Silences playback and cancels scheduling, + /// preserving ONLY the station id; the schedule is wall-clock-stale by resume + /// time, so resume re-fetches. Bumps playGeneration so in-flight work from + /// before the pause cannot publish state after it. + public func pauseForInterruption() { + playGeneration += 1 + isSuspended = true + wasPlayingBeforeInterruption = isPlaying + interruptedStationId = stationId + schedulingTask?.cancel() + schedulingTask = nil + for player in _spinPlayers { player.stop() } + for (_, downloadId) in activeDownloadIds { + _ = fileDownloadManager.cancelDownload(id: downloadId) + } + activeDownloadIds.removeAll() + if case .playing(let spin) = state { state = .paused(spin) } + } + + /// Host-driven resume. Re-activates the session via the manager seam (no-op + /// in .hostOwned — the host must have activated already), restarts the engine, + /// and replays the interrupted station re-synced to the current wall clock. + /// Throws so the host coordinator can render failures. No-op if nothing was + /// interrupted OR if playback wasn't active when the pause happened. + public func resumeAfterInterruption() async throws { + guard let stationToResume = interruptedStationId, + wasPlayingBeforeInterruption + else { return } + isSuspended = false + defer { + interruptedStationId = nil + wasPlayingBeforeInterruption = false + } + try await mainMixer.audioSessionManager.activate() + // restartEngine() before play(): after an interruption the engine may be in + // a stopped-but-stale CoreAudio state; play()'s lazy engine start does not + // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is the + // existing legacy-resume behavior and is idempotent when healthy. + try await mainMixer.restartEngine() + try await play(stationId: stationToResume) + } + #if os(iOS) || os(tvOS) // `object: nil` is intentional and name-scoped: it removes only these two // named registrations, not the AVAudioEngineConfigurationChange observer. @@ -922,7 +968,7 @@ final public class PlayolaStationPlayer: ObservableObject { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) && wasPlayingBeforeInterruption { - resumeAfterInterruption() + resumeAfterInterruptionFromNotification() } @unknown default: @@ -942,31 +988,16 @@ final public class PlayolaStationPlayer: ObservableObject { } if wasPlayingBeforeInterruption { - resumeAfterInterruption() + resumeAfterInterruptionFromNotification() } } - private func resumeAfterInterruption() { - guard let stationToResume = interruptedStationId else { return } - - os_log("Resuming playback after interruption", log: PlayolaStationPlayer.logger, type: .info) - + private func resumeAfterInterruptionFromNotification() { Task { @MainActor in - do { - try await mainMixer.audioSessionManager.activate() - try await mainMixer.restartEngine() - try await self.play(stationId: stationToResume) - } catch { - os_log( - "Failed to resume after interruption: %@", - log: PlayolaStationPlayer.logger, type: .error, - error.localizedDescription) + do { try await resumeAfterInterruption() } catch { await errorReporter.reportError( error, context: "Failed to resume playback after interruption", level: .error) } - - self.interruptedStationId = nil - self.wasPlayingBeforeInterruption = false } } #endif diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index a098aa7..760da87 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -121,4 +121,52 @@ struct AudioSessionOwnershipTests { #expect(player.schedulingTask?.isCancelled == false) } #endif + + @Test("pauseForInterruption bumps generation, publishes .paused, keeps station id") + func pauseForInterruptionBumpsGenerationPublishesPausedAndKeepsStationId() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + let generationBefore = player.playGeneration + + player.pauseForInterruption() + + #expect(player.playGeneration == generationBefore + 1) + #expect(player.stationId == "station-1") + #expect(player.isCurrentGeneration(generationBefore) == false) + if case .paused(let spin) = player.state { + #expect(spin.id == Spin.mock.id) + } else { + Issue.record("pause must publish .paused, got \(player.state)") + } + } + + @Test("resumeAfterInterruption without prior pause is a no-op") + func resumeWithoutPriorPauseIsNoOp() async throws { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + try await player.resumeAfterInterruption() + #expect(player.isPlaying == false) + } + + @Test("pause while not playing does not arm resume") + func pauseWhileNotPlayingDoesNotArmResume() async throws { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + player.setStateForTesting(.idle, stationId: "station-1") + + player.pauseForInterruption() + try await player.resumeAfterInterruption() + + #expect(player.isPlaying == false) + } } diff --git a/Tests/PlayolaPlayerTests/PlayolaStationPlayerScheduleRetryTests.swift b/Tests/PlayolaPlayerTests/PlayolaStationPlayerScheduleRetryTests.swift index 4acb904..a3ad443 100644 --- a/Tests/PlayolaPlayerTests/PlayolaStationPlayerScheduleRetryTests.swift +++ b/Tests/PlayolaPlayerTests/PlayolaStationPlayerScheduleRetryTests.swift @@ -205,6 +205,7 @@ private final class StateRecorder: PlayolaStationPlayerDelegate { case .playing: tag = "playing" case .idle: tag = "idle" case .error: tag = "error" + case .paused: tag = "paused" } MainActor.assumeIsolated { tags.append(tag) } } From 5ee19f00c7412c0cf3ba763dd073c34dd3372ef2 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:17:13 -0500 Subject: [PATCH 09/20] =?UTF-8?q?fix(sdk):=20pause=20hazards=20=E2=80=94?= =?UTF-8?q?=20loading=20state,=20double-pause,=20example=20app=20.paused?= =?UTF-8?q?=20handling=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlayolaPlayerExample/ContentView.swift | 26 +++++++++++-- .../Player/PlayolaStationPlayer.swift | 24 ++++++++++-- .../AudioSessionOwnershipTests.swift | 38 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift b/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift index a68ebd2..83fe2f3 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift @@ -129,6 +129,16 @@ struct ContentView: View { .font(.caption) .foregroundColor(.white.opacity(0.6)) } + } else if case .paused(let spin) = player.state { + Text(spin.audioBlock.title) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + + Text("Paused") + .font(.headline) + .foregroundColor(.white.opacity(0.5)) } else if case .error(let error) = player.state { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") @@ -249,8 +259,9 @@ struct ContentView: View { case .playing: // Stop playing await player.stop() - case .idle, .error: - // Start (or retry after a failed start) + case .idle, .error, .paused: + // Start (or retry after a failed start / resume after a pause — + // play() re-fetches the schedule and re-syncs to now) do { try await player.play(stationId: selectedStationId) } catch { @@ -508,6 +519,8 @@ func buttonColor(for state: PlayolaStationPlayer.State) -> Color { return Color.green case .error: return Color.green + case .paused: + return Color.green } } @@ -521,14 +534,19 @@ func buttonIcon(for state: PlayolaStationPlayer.State) -> String { return "play.fill" case .error: return "arrow.clockwise" + case .paused: + return "play.fill" } } func shouldOffsetIcon(for state: PlayolaStationPlayer.State) -> Bool { - if case .idle = state { + // Offset centers the play triangle; .paused shows play.fill like .idle. + switch state { + case .idle, .paused: return true + default: + return false } - return false } // Custom button style for offset buttons diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 5476123..d118a44 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -790,6 +790,7 @@ final public class PlayolaStationPlayer: ObservableObject { var isSuspendedForTesting: Bool { isSuspended } var interruptedStationIdForTesting: String? { interruptedStationId } + var wasPlayingBeforeInterruptionForTesting: Bool { wasPlayingBeforeInterruption } /// Stops the current playback and releases associated resources. /// @@ -851,9 +852,22 @@ final public class PlayolaStationPlayer: ObservableObject { /// before the pause cannot publish state after it. public func pauseForInterruption() { playGeneration += 1 + // Loading counts as "playing" for resume purposes: if the user started a + // station and a call interrupts the load, they expect it back afterward. + let wasActive: Bool = { + switch state { + case .playing, .loading: return true + case .idle, .paused, .error: return false + } + }() + // A repeated pause (e.g. interruption + route change for the same outage) + // must not overwrite the armed resume state. Pausing while inactive arms + // nothing — there is no playback to bring back. + if !isSuspended { + wasPlayingBeforeInterruption = wasActive + interruptedStationId = wasActive ? stationId : nil + } isSuspended = true - wasPlayingBeforeInterruption = isPlaying - interruptedStationId = stationId schedulingTask?.cancel() schedulingTask = nil for player in _spinPlayers { player.stop() } @@ -861,7 +875,11 @@ final public class PlayolaStationPlayer: ObservableObject { _ = fileDownloadManager.cancelDownload(id: downloadId) } activeDownloadIds.removeAll() - if case .playing(let spin) = state { state = .paused(spin) } + switch state { + case .playing(let spin): state = .paused(spin) + case .loading: state = .idle // no spin to show; resume re-fetches and republishes .loading + default: break + } } /// Host-driven resume. Re-activates the session via the manager seam (no-op diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index 760da87..e55f297 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -153,6 +153,7 @@ struct AudioSessionOwnershipTests { player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) try await player.resumeAfterInterruption() #expect(player.isPlaying == false) + #expect(player.interruptedStationIdForTesting == nil) } @Test("pause while not playing does not arm resume") @@ -168,5 +169,42 @@ struct AudioSessionOwnershipTests { try await player.resumeAfterInterruption() #expect(player.isPlaying == false) + #expect(player.interruptedStationIdForTesting == nil) + } + + @Test("Double pause keeps resume armed") + func doublePauseKeepsResumeArmed() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + + player.pauseForInterruption() + player.pauseForInterruption() + + #expect(player.isSuspendedForTesting == true) + #expect(player.interruptedStationIdForTesting == "station-1") + #expect(player.wasPlayingBeforeInterruptionForTesting == true) + } + + @Test("Pause during loading arms resume and clears the spinner state") + func pauseDuringLoadingArmsResumeAndClearsSpinner() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), + urlSession: MockURLSession(), + mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) + player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) + player.setStateForTesting(.loading(0.5), stationId: "station-1") + + player.pauseForInterruption() + + if case .idle = player.state { + } else { + Issue.record("pause during loading must clear the spinner, got \(player.state)") + } + #expect(player.wasPlayingBeforeInterruptionForTesting == true) + #expect(player.interruptedStationIdForTesting == "station-1") } } From decf502a92068b06169e03320aebf095bb76f991 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:21:27 -0500 Subject: [PATCH 10/20] docs(sdk): document host audio-session ownership contract --- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e12f71f..0db5f19 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,12 @@ class PlayerViewModel: ObservableObject { case .playing(let spin): self?.nowPlaying = "\(spin.audioBlock.title) by \(spin.audioBlock.artist)" self?.isLoading = false + case .paused(let spin): + self?.nowPlaying = "\(spin.audioBlock.title) by \(spin.audioBlock.artist) (paused)" + self?.isLoading = false + case .error(let error): + self?.nowPlaying = "Error: \(error.localizedDescription)" + self?.isLoading = false } } .store(in: &cancellables) @@ -249,6 +255,20 @@ struct PlayerView: View { .foregroundColor(.secondary) } } + case .paused(let spin): + VStack(spacing: 5) { + Text(spin.audioBlock.title) + .font(.title3) + .fontWeight(.semibold) + Text(spin.audioBlock.artist) + .foregroundColor(.secondary) + Text("Paused") + .font(.caption) + .foregroundColor(.orange) + } + case .error(let error): + Text("Error: \(error.localizedDescription)") + .foregroundColor(.red) } } .padding() @@ -309,14 +329,14 @@ extension PlayerViewController: PlayolaStationPlayerDelegate { self?.updateUI(title: spin.audioBlock.title, subtitle: spin.audioBlock.artist) - // Access additional metadata - if let duration = spin.audioBlock.durationMS { - print("Duration: \(duration / 1000) seconds") - } - if let imageUrl = spin.audioBlock.imageUrl { self?.loadAlbumArt(from: imageUrl) } + case .paused(let spin): + self?.updateUI(title: spin.audioBlock.title, + subtitle: "\(spin.audioBlock.artist) (paused)") + case .error(let error): + self?.updateUI(title: "Error", subtitle: error.localizedDescription) } } } @@ -476,7 +496,9 @@ error.playolaReport(context: "Custom operation failed", level: .warning) ### Audio Session Management -PlayolaPlayer automatically manages audio sessions, but you can customize the behavior: +In the default `.sdkOwned` mode, PlayolaPlayer automatically manages the `AVAudioSession` — configuring it, handling interruptions, and reacting to route changes. For apps that need to own the session themselves, see [Host Audio-Session Ownership](#host-audio-session-ownership) below. + +In `.sdkOwned` mode you can forward system notifications to the SDK if you prefer to handle the registration yourself: ```swift import AVFoundation @@ -500,6 +522,107 @@ NotificationCenter.default.addObserver( } ``` +### Host Audio-Session Ownership + +By default the SDK owns the process-global `AVAudioSession`: it configures the `.playback` category, activates and deactivates the session, and self-handles interruptions and route changes. This is the right choice for apps where PlayolaPlayer is the only audio subsystem. + +If your app manages multiple audio subsystems (e.g. it mixes VoIP, music, and radio layers), you can transfer session ownership to the host with the `.hostOwned` mode: + +```swift +PlayolaStationPlayer.shared.configure( + authProvider: myAuthProvider, + audioSessionOwnership: .hostOwned +) +``` + +#### Ownership modes + +| Mode | Who configures/activates `AVAudioSession` | SDK observes interruptions? | +|---|---|---| +| `.sdkOwned` (default) | SDK | Yes — auto-stops and auto-resumes | +| `.hostOwned` | Host app | No — host is responsible for all policy | + +#### Host-mode contract + +When using `.hostOwned` the host app is responsible for the following: + +1. **Configure and activate the session before calling `play(stationId:)` or `resumeAfterInterruption()`.** The SDK does not validate this precondition. If the session is not ready when the AVAudioEngine starts, the engine throws; the error surfaces through the normal error path (`.error` state / thrown error from `play(stationId:)`), not a crash. + +2. **Own all interruption and route-change policy.** The SDK removes its `AVAudioSession` observers in `.hostOwned` mode and never auto-stops or auto-resumes playback. Your app decides when to pause and resume. + +3. **The SDK still observes `AVAudioEngineConfigurationChange`.** This notification is engine-internal and ownership-independent; the SDK handles it in both modes. + +#### Interruption handling in host-owned mode + +Call `pauseForInterruption()` synchronously when an interruption begins and `resumeAfterInterruption()` asynchronously when it ends: + +```swift +import AVFoundation +import PlayolaPlayer + +// Register in your audio coordinator's init +NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main +) { notification in + guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + // Silence the engine immediately; preserves stationId for resume + PlayolaStationPlayer.shared.pauseForInterruption() + + case .ended: + guard let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt, + AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) + else { return } + + Task { + do { + // Host must reactivate the session before resuming the SDK + try AVAudioSession.sharedInstance().setActive(true) + try await PlayolaStationPlayer.shared.resumeAfterInterruption() + } catch { + print("Resume failed: \(error)") + } + } + + @unknown default: + break + } +} +``` + +#### Pause semantics for wall-clock radio + +PlayolaPlayer streams a live wall-clock schedule — there is no "frozen" playback position. `pauseForInterruption()` preserves only the station identity; `resumeAfterInterruption()` re-fetches the current schedule and re-syncs to the live wall clock. The station resumes at the correct "right now" position, not where it left off before the interruption. + +While paused, the player publishes `.paused(Spin)` state (the `Spin` carries the display metadata — title, artist, artwork URL — of the track that was playing at pause time). Update your UI accordingly: + +```swift +PlayolaStationPlayer.shared.$state + .sink { state in + switch state { + case .idle: + updateUI(title: "Not playing") + case .loading: + updateUI(title: "Loading...") + case .playing(let spin): + updateUI(title: spin.audioBlock.title, artist: spin.audioBlock.artist) + case .paused(let spin): + updateUI(title: spin.audioBlock.title, artist: spin.audioBlock.artist, paused: true) + case .error(let error): + updateUI(title: "Error", subtitle: error.localizedDescription) + } + } + .store(in: &cancellables) +``` + +> **Note:** If your code has an exhaustive `switch` over `PlayolaStationPlayer.State`, add a `.paused` case — it was introduced alongside this feature. + ### File Download Management Work with the file download and caching system: @@ -708,27 +831,42 @@ final public class PlayolaStationPlayer: ObservableObject { @Published public var state: State // Configuration - public func configure(authProvider: PlayolaAuthenticationProvider, baseURL: URL = default) + public func configure( + authProvider: PlayolaAuthenticationProvider, + baseURL: URL = default, + audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned + ) // Playback control public func play(stationId: String, atDate: Date? = nil) async throws public func stop() + // Interruption handling (host-owned mode) + public func pauseForInterruption() + public func resumeAfterInterruption() async throws + // Status checking public var isPlaying: Bool { get } // Delegate support public weak var delegate: PlayolaStationPlayerDelegate? - // Audio session handling (iOS only) + // Audio session handling (iOS only, .sdkOwned mode) public func handleAudioSessionInterruption(_ notification: Notification) public func handleAudioRouteChange(_ notification: Notification) } public enum State: Sendable { case idle - case loading(Float) // Progress 0.0-1.0 + case loading(Float) // Progress 0.0-1.0 case playing(Spin) + case paused(Spin) // Interrupted; Spin is display metadata only + case error(StationPlayerError) +} + +public enum PlayolaAudioSessionOwnership: Sendable, Equatable { + case sdkOwned // SDK manages AVAudioSession (default) + case hostOwned // Host app manages AVAudioSession } ``` From 6819668d1679e894dfa6773cf448f02ffdceadce Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:33:44 -0500 Subject: [PATCH 11/20] fix(sdk): lazy shared-mixer resolution, safe ownership fallback, stale-download audio guard (final review) --- .../Player/PlayolaStationPlayer.swift | 21 ++++++++++++------- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 9 ++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index d118a44..c1cbfba 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -68,8 +68,11 @@ final public class PlayolaStationPlayer: ObservableObject { private let errorReporter = PlayolaErrorReporter.shared private var authProvider: PlayolaAuthenticationProvider? private let urlSession: URLSessionProtocol - /// Injectable for tests; production uses .shared. - let mainMixer: PlayolaMainMixer + private let injectedMainMixer: PlayolaMainMixer? + /// Resolved lazily so merely constructing a station player (e.g. in headless + /// macOS test runs) does not build the shared CoreAudio graph — matching + /// pre-ownership behavior, where init only touched the mixer on iOS/tvOS. + var mainMixer: PlayolaMainMixer { injectedMainMixer ?? .shared } private var audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned /// Whether the SDK reacts to AVAudioSession interruption/route events itself. var handlesSessionEventsInternally: Bool { audioSessionOwnership == .sdkOwned } @@ -135,10 +138,11 @@ final public class PlayolaStationPlayer: ObservableObject { ) { self.authProvider = authProvider mainMixer.applyOwnership(audioSessionOwnership) - // Mirror what the mixer actually latched — a conflicting re-configure is - // asserted in debug and silently ignored in release; mirroring keeps the - // notification-handler gate consistent with the real session manager. - self.audioSessionOwnership = mainMixer.appliedOwnership ?? audioSessionOwnership + // Mirror what the mixer actually latched. If the latch was rejected (late + // .hostOwned after the session was touched — asserted in debug, ignored in + // release), fall back to .sdkOwned: keeping legacy handling active is safe; + // pretending host mode is active while the SDK still owns the session is not. + self.audioSessionOwnership = mainMixer.appliedOwnership ?? .sdkOwned #if os(iOS) || os(tvOS) if self.audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } #endif @@ -184,7 +188,7 @@ final public class PlayolaStationPlayer: ObservableObject { ) { self.fileDownloadManager = fileDownloadManager ?? FileDownloadManagerAsync.shared self.urlSession = urlSession - self.mainMixer = mainMixer ?? .shared + self.injectedMainMixer = mainMixer self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) @@ -220,6 +224,9 @@ final public class PlayolaStationPlayer: ObservableObject { let availablePlayers = _spinPlayers.filter({ $0.state == .available }) if let available = availablePlayers.first { return available } + // Note: SpinPlayer currently couples to PlayolaMainMixer.shared internally + // (it ignores an injected mixer). Fine in production where everything uses + // .shared; threading injection through SpinPlayer is a known follow-up. let newPlayer = SpinPlayer(delegate: self) _spinPlayers.append(newPlayer) return newPlayer diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index d6935d1..652120b 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -612,6 +612,15 @@ public class SpinPlayer { continuation: CheckedContinuation, Never> ) { Task { @MainActor in + // A stop()/pauseForInterruption() between download completion and this hop + // clears `spin`. Bail before touching the engine or scheduling playback — + // otherwise a stale download starts audio the station player already + // silenced (state stays .paused/.idle while sound plays). + guard self.spin?.id == spin.id else { + continuation.resume(returning: .failure(FileDownloadError.downloadCancelled)) + return + } + await self.loadFile(with: localUrl) // Ensure audio session is configured and engine is started before playback. From fdc3970a2792acf117de9c7af4d4cd9cbc3b4050 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 18:56:43 -0500 Subject: [PATCH 12/20] fix: CI hang (no CoreAudio on headless runners), lint, review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - configure() no longer resolves the shared mixer on the default path (.sdkOwned, no injection) — develop's configure never touched the mixer, and resolving .shared builds the CoreAudio graph, which hangs headless CircleCI runs that merely construct + configure a player - gate AudioSessionOwnershipTests behind CI env check: every test in the suite constructs PlayolaMainMixer (same failure class as e8604a5) - guard handleAudioEngineConfigurationChange on handlesSessionEventsInternally: auto-resume is interruption policy and must not race a host-driven resume (greptile outside-diff #1) - pauseForInterruption cancels playTask for symmetry with stop() (greptile outside-diff #2) - seam test: graceful skip when sources aren't present at runtime (compile-time #filePath; greptile outside-diff #4) - lint: file_length disable pair on PlayolaStationPlayer.swift (matches SpinPlayer precedent), for-where + line length in seam test --- .../Player/PlayolaStationPlayer.swift | 41 +++++++++++++++---- .../AudioSessionOwnershipTests.swift | 11 +++++ .../SessionSeamInvariantTests.swift | 16 +++++--- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index c1cbfba..76700fc 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -4,7 +4,7 @@ // // Created by Brian D Keane on 12/29/24. // - +// swiftlint:disable file_length import AVFAudio import Combine import Foundation @@ -69,6 +69,10 @@ final public class PlayolaStationPlayer: ObservableObject { private var authProvider: PlayolaAuthenticationProvider? private let urlSession: URLSessionProtocol private let injectedMainMixer: PlayolaMainMixer? + /// Whether a configure() call has already pushed ownership into the mixer. + /// Once true, every re-configure must go through applyOwnership so conflicts + /// keep being detected (and the mirror stays honest). + private var hasAppliedOwnershipToMixer = false /// Resolved lazily so merely constructing a station player (e.g. in headless /// macOS test runs) does not build the shared CoreAudio graph — matching /// pre-ownership behavior, where init only touched the mixer on iOS/tvOS. @@ -137,12 +141,24 @@ final public class PlayolaStationPlayer: ObservableObject { audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned ) { self.authProvider = authProvider - mainMixer.applyOwnership(audioSessionOwnership) - // Mirror what the mixer actually latched. If the latch was rejected (late - // .hostOwned after the session was touched — asserted in debug, ignored in - // release), fall back to .sdkOwned: keeping legacy handling active is safe; - // pretending host mode is active while the SDK still owns the session is not. - self.audioSessionOwnership = mainMixer.appliedOwnership ?? .sdkOwned + // Resolve the mixer only when ownership work actually requires it. The + // default path (.sdkOwned, no injected mixer) keeps init's manager anyway, + // and resolving `.shared` here would construct the CoreAudio graph — which + // hangs headless CI runs that merely construct + configure a player + // (develop's configure never touched the mixer; preserve that). + if audioSessionOwnership != .sdkOwned || injectedMainMixer != nil + || hasAppliedOwnershipToMixer + { + mainMixer.applyOwnership(audioSessionOwnership) + hasAppliedOwnershipToMixer = true + // Mirror what the mixer actually latched. If the latch was rejected (late + // .hostOwned after the session was touched — asserted in debug, ignored in + // release), fall back to .sdkOwned: keeping legacy handling active is safe; + // pretending host mode is active while the SDK still owns the session is not. + self.audioSessionOwnership = mainMixer.appliedOwnership ?? .sdkOwned + } else { + self.audioSessionOwnership = .sdkOwned + } #if os(iOS) || os(tvOS) if self.audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } #endif @@ -877,6 +893,10 @@ final public class PlayolaStationPlayer: ObservableObject { isSuspended = true schedulingTask?.cancel() schedulingTask = nil + // Symmetry with stop(): playTask is currently never assigned a live task, + // but cancel it anyway so pause keeps fencing it if that ever changes. + playTask?.cancel() + playTask = nil for player in _spinPlayers { player.stop() } for (_, downloadId) in activeDownloadIds { _ = fileDownloadManager.cancelDownload(id: downloadId) @@ -1006,6 +1026,12 @@ final public class PlayolaStationPlayer: ObservableObject { @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) + // The auto-resume below is interruption POLICY, not engine bookkeeping — + // in host mode the app owns that policy and drives resume explicitly, + // and an unguarded auto-resume here could race a host-initiated + // resumeAfterInterruption() (double play()). + guard handlesSessionEventsInternally else { return } + guard !isSuspended else { os_log( "Ignoring config change while suspended", log: PlayolaStationPlayer.logger, type: .info) @@ -1086,3 +1112,4 @@ public protocol PlayolaStationPlayerDelegate: AnyObject { _ player: PlayolaStationPlayer, playerStateDidChange state: PlayolaStationPlayer.State) } +// swiftlint:enable file_length diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift index e55f297..88d633e 100644 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift @@ -2,6 +2,7 @@ // PlayolaPlayer import AVFAudio +import Foundation import Testing @testable import PlayolaPlayer @@ -14,7 +15,17 @@ final class SpyAudioSessionManager: AudioSessionManaging { func deactivate() async throws {} } +/// Headless CI macOS runners hang when a real AVAudioEngine graph is +/// constructed (no audio HAL session) — the same failure class as the +/// SpinPlayer CI hang fixed in e8604a5. The tests in this suite construct +/// PlayolaMainMixer (directly or injected into a station player), so the +/// suite runs only where CoreAudio is available (local dev machines). +private var coreAudioGraphAvailable: Bool { + ProcessInfo.processInfo.environment["CI"] == nil +} + @MainActor +@Suite(.enabled(if: coreAudioGraphAvailable)) struct AudioSessionOwnershipTests { @Test("NoOp manager is inert and reports isConfigured = true in host mode") func noOpManagerIsInertAndReportsConfigured() async throws { diff --git a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift index c9bb6d9..d5d9164 100644 --- a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift +++ b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift @@ -16,6 +16,10 @@ struct SessionSeamInvariantTests { thisFile // Tests/PlayolaPlayerTests/ -> repo root .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() let sourcesDir = repoRoot.appendingPathComponent("Sources") + // #filePath is compile-time: on runners that execute a binary built + // elsewhere (Xcode Cloud, device farms) the sources won't exist. Skip + // gracefully there — the invariant is enforced wherever sources are local. + guard FileManager.default.fileExists(atPath: sourcesDir.path) else { return } guard let enumerator = FileManager.default.enumerator( at: sourcesDir, includingPropertiesForKeys: nil) @@ -27,12 +31,12 @@ struct SessionSeamInvariantTests { for case let url as URL in enumerator where url.pathExtension == "swift" { guard url.lastPathComponent != "AudioSessionManager.swift" else { continue } let content = try String(contentsOf: url, encoding: .utf8) - // Direct access AND the two session-mutating verbs (catches alias/wrapper bypass). - // Substring match: a comment mentioning ".setCategory(" would false-positive — acceptable; the failure message names the file so triage is trivial. - for needle in ["AVAudioSession.sharedInstance", ".setCategory(", ".setActive("] { - if content.contains(needle) { - offenders.append("\(url.lastPathComponent): \(needle)") - } + // Direct access AND the two session-mutating verbs (catches alias/wrapper + // bypass). Substring match: a comment mentioning ".setCategory(" would + // false-positive — acceptable; the failure message names the file. + for needle in ["AVAudioSession.sharedInstance", ".setCategory(", ".setActive("] + where content.contains(needle) { + offenders.append("\(url.lastPathComponent): \(needle)") } } #expect(offenders.isEmpty, "Direct session access outside the seam: \(offenders)") From b717c069c01fff349d6ba42935f93436c5e5c2a6 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 19:25:29 -0500 Subject: [PATCH 13/20] refactor(sdk)!: host-only audio-session ownership (remove dual mode) The SDK no longer manages the AVAudioSession at all. Removes the .sdkOwned/.hostOwned dual mode and all of its transitional machinery, which was the source of repeated review churn (split-brain release fallback, late-application ordering, double-configure divergence) and had no value given a single consumer that migrates in lockstep. Deleted: PlayolaAudioSessionOwnership, AudioSessionManager + NoOpAudioSessionManager + AudioSessionManaging (whole file), applyOwnership/appliedOwnership/sessionTouched/hasAppliedOwnershipToMixer, handlesSessionEventsInternally, observer gating, and the legacy interruption/route/engine-config handlers (removing the two known pre-existing legacy bugs with them). configure() loses its audioSessionOwnership parameter. The host now owns the AVAudioSession: it configures/activates it and drives pauseForInterruption()/resumeAfterInterruption() from its own interruption handlers. SpinPlayer/PlayolaMainMixer no longer touch the session; engine.start() throwing on an inactive session surfaces through the normal error path. Unchanged: pauseForInterruption/resumeAfterInterruption/.paused, lazy mixer resolution, stale-download guard. The seam invariant test now asserts the SDK references AVAudioSession nowhere in Sources/. Example app gains its own session setup. README documents the host contract and a 0.19->0.20 migration guide. BREAKING CHANGE: host apps must own the AVAudioSession (see README migration guide). Ships as 0.20.0. --- .../PlayolaPlayerExampleApp.swift | 16 ++ README.md | 93 +++----- .../Player/AudioSessionManager.swift | 127 ---------- .../Player/PlayolaMainMixer.swift | 107 +-------- .../Player/PlayolaStationPlayer.swift | 216 ++--------------- Sources/PlayolaPlayer/Player/SpinPlayer.swift | 37 ++- .../AudioSessionOwnershipTests.swift | 221 ------------------ .../InterruptionTransportTests.swift | 93 ++++++++ .../SessionSeamInvariantTests.swift | 14 +- 9 files changed, 179 insertions(+), 745 deletions(-) delete mode 100644 Sources/PlayolaPlayer/Player/AudioSessionManager.swift delete mode 100644 Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift create mode 100644 Tests/PlayolaPlayerTests/InterruptionTransportTests.swift diff --git a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift index 423a655..353fe41 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift @@ -5,10 +5,26 @@ // Created by Brian D Keane on 12/29/24. // +import AVFoundation import SwiftUI @main struct PlayolaPlayerExampleApp: App { + init() { + // PlayolaPlayer does not manage the AVAudioSession — the host app owns it. + // Configure and activate it for long-form playback before starting a + // station. Real apps should also observe interruption/route notifications + // and drive PlayolaStationPlayer.pauseForInterruption() / + // resumeAfterInterruption(); this example keeps it minimal. + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: []) + try session.setActive(true) + } catch { + print("Failed to configure AVAudioSession: \(error)") + } + } + var body: some Scene { WindowGroup { ContentView() diff --git a/README.md b/README.md index 0db5f19..e7ff560 100644 --- a/README.md +++ b/README.md @@ -494,65 +494,27 @@ PlayolaErrorReporter.shared.reportingLevel = .debug // Reports everything error.playolaReport(context: "Custom operation failed", level: .warning) ``` -### Audio Session Management +### Audio session -In the default `.sdkOwned` mode, PlayolaPlayer automatically manages the `AVAudioSession` — configuring it, handling interruptions, and reacting to route changes. For apps that need to own the session themselves, see [Host Audio-Session Ownership](#host-audio-session-ownership) below. +**PlayolaPlayer does not manage the `AVAudioSession`.** The host app owns it: you configure the category, activate/deactivate the session, and own all interruption and route-change policy. This keeps the SDK composable with the rest of your app's audio (URL streams, recording, VoIP) instead of fighting it for the process-global session. -In `.sdkOwned` mode you can forward system notifications to the SDK if you prefer to handle the registration yourself: +The host is responsible for: -```swift -import AVFoundation +1. **Configure and activate the session before calling `play(stationId:)` or `resumeAfterInterruption()`.** The SDK does not validate this precondition. If the session is not active when the `AVAudioEngine` starts, the engine throws and the error surfaces through the normal error path (`.error` state / thrown error from `play(stationId:)`), not a crash. -// Handle interruptions (phone calls, alarms, etc.) -NotificationCenter.default.addObserver( - forName: AVAudioSession.interruptionNotification, - object: nil, - queue: .main -) { notification in - PlayolaStationPlayer.shared.handleAudioSessionInterruption(notification) -} +2. **Own all interruption and route-change policy.** The SDK registers no `AVAudioSession` observers and never auto-stops or auto-resumes. Your app decides when to pause and resume, and drives the SDK with `pauseForInterruption()` / `resumeAfterInterruption()`. -// Handle route changes (headphones plugged/unplugged) -NotificationCenter.default.addObserver( - forName: AVAudioSession.routeChangeNotification, - object: nil, - queue: .main -) { notification in - PlayolaStationPlayer.shared.handleAudioRouteChange(notification) -} -``` - -### Host Audio-Session Ownership - -By default the SDK owns the process-global `AVAudioSession`: it configures the `.playback` category, activates and deactivates the session, and self-handles interruptions and route changes. This is the right choice for apps where PlayolaPlayer is the only audio subsystem. - -If your app manages multiple audio subsystems (e.g. it mixes VoIP, music, and radio layers), you can transfer session ownership to the host with the `.hostOwned` mode: +Minimal launch-time setup (long-form playback, AirPlay 2 friendly): ```swift -PlayolaStationPlayer.shared.configure( - authProvider: myAuthProvider, - audioSessionOwnership: .hostOwned -) -``` - -#### Ownership modes - -| Mode | Who configures/activates `AVAudioSession` | SDK observes interruptions? | -|---|---|---| -| `.sdkOwned` (default) | SDK | Yes — auto-stops and auto-resumes | -| `.hostOwned` | Host app | No — host is responsible for all policy | - -#### Host-mode contract - -When using `.hostOwned` the host app is responsible for the following: - -1. **Configure and activate the session before calling `play(stationId:)` or `resumeAfterInterruption()`.** The SDK does not validate this precondition. If the session is not ready when the AVAudioEngine starts, the engine throws; the error surfaces through the normal error path (`.error` state / thrown error from `play(stationId:)`), not a crash. - -2. **Own all interruption and route-change policy.** The SDK removes its `AVAudioSession` observers in `.hostOwned` mode and never auto-stops or auto-resumes playback. Your app decides when to pause and resume. +import AVFoundation -3. **The SDK still observes `AVAudioEngineConfigurationChange`.** This notification is engine-internal and ownership-independent; the SDK handles it in both modes. +let session = AVAudioSession.sharedInstance() +try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: []) +try session.setActive(true) +``` -#### Interruption handling in host-owned mode +#### Interruption handling Call `pauseForInterruption()` synchronously when an interruption begins and `resumeAfterInterruption()` asynchronously when it ends: @@ -621,7 +583,17 @@ PlayolaStationPlayer.shared.$state .store(in: &cancellables) ``` -> **Note:** If your code has an exhaustive `switch` over `PlayolaStationPlayer.State`, add a `.paused` case — it was introduced alongside this feature. +### Migrating from 0.19.x to 0.20.0 + +`0.20.0` makes the host the sole owner of the `AVAudioSession`. Earlier versions configured, activated, and self-handled interruptions for you. This is a breaking change; both steps are required. + +1. **Own the session.** Add the launch-time setup from [Audio session](#audio-session) above (`setCategory(.playback, …)` + `setActive(true)`). The SDK no longer does this — without it, `play(stationId:)` fails when the engine starts. + +2. **Drive interruptions yourself.** The SDK no longer observes `AVAudioSession.interruptionNotification` / `routeChangeNotification` and the `handleAudioSessionInterruption(_:)` / `handleAudioRouteChange(_:)` methods are removed. Register your own observers and call `pauseForInterruption()` / `resumeAfterInterruption()` as shown in [Interruption handling](#interruption-handling). Reactivate the session before calling resume. + +3. **Handle `.paused`.** A `.paused(Spin)` state was added to `PlayolaStationPlayer.State`. Any exhaustive `switch` over the state must add a `.paused` case (the compiler will flag every site). + +4. **`configure(…)` lost its `audioSessionOwnership` parameter.** Host ownership is now the only mode; remove the argument if you passed it. ### File Download Management @@ -833,15 +805,14 @@ final public class PlayolaStationPlayer: ObservableObject { // Configuration public func configure( authProvider: PlayolaAuthenticationProvider, - baseURL: URL = default, - audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned + baseURL: URL = default ) // Playback control public func play(stationId: String, atDate: Date? = nil) async throws public func stop() - // Interruption handling (host-owned mode) + // Interruption handling — the host calls these (it owns the AVAudioSession) public func pauseForInterruption() public func resumeAfterInterruption() async throws @@ -850,10 +821,6 @@ final public class PlayolaStationPlayer: ObservableObject { // Delegate support public weak var delegate: PlayolaStationPlayerDelegate? - - // Audio session handling (iOS only, .sdkOwned mode) - public func handleAudioSessionInterruption(_ notification: Notification) - public func handleAudioRouteChange(_ notification: Notification) } public enum State: Sendable { @@ -863,11 +830,6 @@ public enum State: Sendable { case paused(Spin) // Interrupted; Spin is display metadata only case error(StationPlayerError) } - -public enum PlayolaAudioSessionOwnership: Sendable, Equatable { - case sdkOwned // SDK manages AVAudioSession (default) - case hostOwned // Host app manages AVAudioSession -} ``` #### SpinPlayer @@ -909,9 +871,8 @@ open class PlayolaMainMixer { public let engine: AVAudioEngine public weak var delegate: PlayolaMainMixerDelegate? - public func configureAudioSession() - public func deactivateAudioSession() - public func start() throws + public func start() async throws + public func restartEngine() async throws public func attach(_ node: AVAudioPlayerNode) public func connect(_ playerNode: AVAudioPlayerNode, to mixerNode: AVAudioMixerNode, format: AVAudioFormat?) public func prepare() diff --git a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift b/Sources/PlayolaPlayer/Player/AudioSessionManager.swift deleted file mode 100644 index bbe6791..0000000 --- a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// AudioSessionManager.swift -// PlayolaPlayer -// -// Created by Brian D Keane on 8/8/25. -// - -import AVFoundation -import Foundation -import PlayolaCore - -/// Protocol for managing audio session configuration across platforms -protocol AudioSessionManaging { - func configureForPlayback() async throws - func activate() async throws - func deactivate() async throws - var isConfigured: Bool { get } -} - -/// Inert session manager used when the HOST APP owns the process-global -/// AVAudioSession (see `PlayolaAudioSessionOwnership.hostOwned`). -/// -/// Reports `isConfigured == true` so SDK guards that gate on configuration -/// become pass-throughs. HOST CONTRACT: the app must configure (.playback) -/// and activate the session before play()/resumeAfterInterruption(); if it -/// doesn't, engine.start() throws and surfaces through the SDK error path. -final class NoOpAudioSessionManager: AudioSessionManaging { - var isConfigured: Bool { true } - init() {} - func configureForPlayback() async throws {} - func activate() async throws {} - func deactivate() async throws {} -} - -/// Who owns the process-global AVAudioSession. -public enum PlayolaAudioSessionOwnership: Sendable, Equatable { - /// SDK configures/activates the session itself (legacy default). - case sdkOwned - /// Host app owns the session; the SDK never touches it and registers no - /// AVAudioSession observers. See host-mode contract in README. - case hostOwned -} - -#if os(iOS) || os(tvOS) - /// iOS/tvOS implementation using AVAudioSession - class AudioSessionManager: AudioSessionManaging { - private let errorReporter: PlayolaErrorReporter - - var isConfigured: Bool { - let session = AVAudioSession.sharedInstance() - return session.category == .playback - } - - init(errorReporter: PlayolaErrorReporter = .shared) { - self.errorReporter = errorReporter - } - - func configureForPlayback() async throws { - let session = AVAudioSession.sharedInstance() - - // First deactivate with appropriate options to reset state - do { - print( - "🔊 Calling session.setActive(false, options: .notifyOthersOnDeactivation)" - ) - try session.setActive(false, options: .notifyOthersOnDeactivation) - print("🔊 Successfully deactivated session") - } catch { - print("🔊 Error deactivating session: \(error)") - // This is not a critical error, just log it - await errorReporter.reportError( - error, - context: - "Non-critical error deactivating audio session before configuration", - level: .warning - ) - } - - // Configure for playback category - try session.setCategory( - .playback, - mode: .default, - options: [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] - ) - } - - func activate() async throws { - if !isConfigured { - try await configureForPlayback() - } - - let session = AVAudioSession.sharedInstance() - try session.setActive(true) - } - - func deactivate() async throws { - guard isConfigured else { return } - - let session = AVAudioSession.sharedInstance() - try session.setActive(false, options: .notifyOthersOnDeactivation) - } - } -#endif - -#if os(macOS) - /// macOS implementation - audio session management is handled automatically - class AudioSessionManager: AudioSessionManaging { - var isConfigured: Bool { true } // Always configured on macOS - - init(errorReporter: PlayolaErrorReporter = .shared) { - // errorReporter not needed on macOS but kept for API compatibility - } - - func configureForPlayback() async throws { - // On macOS, audio configuration is handled automatically by the system - // No explicit session configuration needed - } - - func activate() async throws { - // On macOS, audio activation is handled automatically - } - - func deactivate() async throws { - // On macOS, audio deactivation is handled automatically - } - } -#endif diff --git a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift index e97218b..603b943 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -40,21 +40,12 @@ open class PlayolaMainMixer: NSObject { open var delegate: PlayolaMainMixerDelegate? private let errorReporter = PlayolaErrorReporter.shared - private(set) var audioSessionManager: any AudioSessionManaging - /// The ownership actually latched by the first applyOwnership call. - /// Internal so PlayolaStationPlayer.configure can stay consistent with it - /// even when a conflicting re-configure is silently ignored in release. - private(set) var appliedOwnership: PlayolaAudioSessionOwnership? - /// True once any session-touching call has run (configure/ensure/deactivate). - /// Used to fail loudly when ownership arrives too late. - private var sessionTouched = false private static let logger = OSLog(subsystem: "fm.playola.playolaCore", category: "MainMixer") - init(audioSessionManager: (any AudioSessionManaging)? = nil) { + override init() { self.mixerNode = AVAudioMixerNode() self.engine = AVAudioEngine() - self.audioSessionManager = audioSessionManager ?? AudioSessionManager() super.init() self.engine.attach(self.mixerNode) @@ -77,107 +68,11 @@ open class PlayolaMainMixer: NSObject { self.mixerNode.removeTap(onBus: 0) } - /// Configures the shared audio session for playback (fire-and-forget) - @MainActor - public func configureAudioSession() { - sessionTouched = true - guard !audioSessionManager.isConfigured else { return } - - Task { @MainActor in - do { - try await audioSessionManager.configureForPlayback() - try await audioSessionManager.activate() - os_log("Audio session successfully configured", log: PlayolaMainMixer.logger, type: .info) - } catch { - let deviceName = DeviceInfoProvider.deviceName - let systemVersion = DeviceInfoProvider.systemVersion - await errorReporter.reportError( - error, - context: - "Failed to configure audio session | Device: \(deviceName) | OS: \(systemVersion)", - level: .critical) - } - } - } - - /// Configures the shared audio session for playback and waits for completion. - /// Use this before engine.start() to avoid stalling the audio engine. - @MainActor - public func ensureAudioSessionConfigured() async throws { - sessionTouched = true - guard !audioSessionManager.isConfigured else { return } - - do { - try await audioSessionManager.configureForPlayback() - try await audioSessionManager.activate() - os_log("Audio session successfully configured", log: PlayolaMainMixer.logger, type: .info) - } catch { - let deviceName = DeviceInfoProvider.deviceName - let systemVersion = DeviceInfoProvider.systemVersion - await errorReporter.reportError( - error, - context: - "Failed to configure audio session | Device: \(deviceName) | OS: \(systemVersion)", - level: .critical) - throw error - } - } - - /// Deactivates the audio session when it's no longer needed - @MainActor - public func deactivateAudioSession() { - sessionTouched = true - guard audioSessionManager.isConfigured else { return } - - Task { @MainActor in - do { - try await audioSessionManager.deactivate() - os_log("Audio session deactivated", log: PlayolaMainMixer.logger, type: .info) - } catch { - await errorReporter.reportError( - error, context: "Failed to deactivate audio session", level: .warning) - } - } - } - /// Handles the audio tap private func onTap(_ buffer: AVAudioPCMBuffer, _ time: AVAudioTime) { self.delegate?.player(self, didPlayBuffer: buffer) } - /// Applies the ownership choice made in PlayolaStationPlayer.configure. - /// Idempotent for repeated same-value calls. Programmer errors fail loudly in - /// debug (assertionFailure) and are ignored in release: - /// - conflicting values, - /// - any call after the engine has started, - /// - switching to .hostOwned after the SDK already touched the session - /// (e.g. a SpinPlayer was constructed before configure — SpinPlayer.init - /// calls configureAudioSession()). - @MainActor - func applyOwnership(_ ownership: PlayolaAudioSessionOwnership) { - if let applied = appliedOwnership { - if applied != ownership { - assertionFailure("Conflicting audio-session ownership: \(applied) -> \(ownership)") - } - return - } - guard !engine.isRunning else { - assertionFailure("applyOwnership must be called before the engine starts") - return - } - if ownership == .hostOwned && sessionTouched { - assertionFailure( - "applyOwnership(.hostOwned) after the SDK already configured the session — " - + "call configure(audioSessionOwnership:) before creating any playback objects") - return - } - appliedOwnership = ownership - switch ownership { - case .sdkOwned: break // keep the AudioSessionManager from init - case .hostOwned: audioSessionManager = NoOpAudioSessionManager() - } - } - @MainActor public static let shared: PlayolaMainMixer = PlayolaMainMixer() } diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 76700fc..bee3702 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -4,7 +4,6 @@ // // Created by Brian D Keane on 12/29/24. // -// swiftlint:disable file_length import AVFAudio import Combine import Foundation @@ -68,18 +67,10 @@ final public class PlayolaStationPlayer: ObservableObject { private let errorReporter = PlayolaErrorReporter.shared private var authProvider: PlayolaAuthenticationProvider? private let urlSession: URLSessionProtocol - private let injectedMainMixer: PlayolaMainMixer? - /// Whether a configure() call has already pushed ownership into the mixer. - /// Once true, every re-configure must go through applyOwnership so conflicts - /// keep being detected (and the mirror stays honest). - private var hasAppliedOwnershipToMixer = false /// Resolved lazily so merely constructing a station player (e.g. in headless - /// macOS test runs) does not build the shared CoreAudio graph — matching - /// pre-ownership behavior, where init only touched the mixer on iOS/tvOS. - var mainMixer: PlayolaMainMixer { injectedMainMixer ?? .shared } - private var audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned - /// Whether the SDK reacts to AVAudioSession interruption/route events itself. - var handlesSessionEventsInternally: Bool { audioSessionOwnership == .sdkOwned } + /// macOS test runs) does not build the shared CoreAudio graph. The SDK touches + /// the mixer only when playback actually starts (or resumes). + var mainMixer: PlayolaMainMixer { .shared } /// Base delay (seconds) for the initial schedule-fetch exponential backoff. /// Internal so tests can disable the wait; not part of the public API. @@ -134,34 +125,16 @@ final public class PlayolaStationPlayer: ObservableObject { /// - Parameters: /// - authProvider: Provider for JWT tokens /// - baseURL: Base URL for API endpoints. Defaults to production URL. - /// - audioSessionOwnership: Who owns AVAudioSession. Defaults to `.sdkOwned`. + /// + /// The SDK does not own the `AVAudioSession`. The host app must configure and + /// activate it (category `.playback`) before calling `play(stationId:)` or + /// `resumeAfterInterruption()`, and owns all interruption/route-change policy. + /// See the README "Audio session" section. public func configure( authProvider: PlayolaAuthenticationProvider, - baseURL: URL = URL(string: "https://admin-api.playola.fm")!, - audioSessionOwnership: PlayolaAudioSessionOwnership = .sdkOwned + baseURL: URL = URL(string: "https://admin-api.playola.fm")! ) { self.authProvider = authProvider - // Resolve the mixer only when ownership work actually requires it. The - // default path (.sdkOwned, no injected mixer) keeps init's manager anyway, - // and resolving `.shared` here would construct the CoreAudio graph — which - // hangs headless CI runs that merely construct + configure a player - // (develop's configure never touched the mixer; preserve that). - if audioSessionOwnership != .sdkOwned || injectedMainMixer != nil - || hasAppliedOwnershipToMixer - { - mainMixer.applyOwnership(audioSessionOwnership) - hasAppliedOwnershipToMixer = true - // Mirror what the mixer actually latched. If the latch was rejected (late - // .hostOwned after the session was touched — asserted in debug, ignored in - // release), fall back to .sdkOwned: keeping legacy handling active is safe; - // pretending host mode is active while the SDK still owns the session is not. - self.audioSessionOwnership = mainMixer.appliedOwnership ?? .sdkOwned - } else { - self.audioSessionOwnership = .sdkOwned - } - #if os(iOS) || os(tvOS) - if self.audioSessionOwnership == .hostOwned { removeAudioSessionObservers() } - #endif self.listeningSessionReporter = ListeningSessionReporter( stationPlayer: self, authProvider: authProvider, baseURL: baseURL) self.baseUrl = baseURL @@ -199,37 +172,15 @@ final public class PlayolaStationPlayer: ObservableObject { @MainActor internal init( fileDownloadManager: FileDownloadManaging? = nil, - urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session), - mainMixer: PlayolaMainMixer? = nil + urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session) ) { self.fileDownloadManager = fileDownloadManager ?? FileDownloadManagerAsync.shared self.urlSession = urlSession - self.injectedMainMixer = mainMixer self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) - - #if os(iOS) || os(tvOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioSessionInterruption(_:)), - name: AVAudioSession.interruptionNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioRouteChange(_:)), - name: AVAudioSession.routeChangeNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioEngineConfigurationChange(_:)), - name: .AVAudioEngineConfigurationChange, - object: self.mainMixer.engine - ) - #endif + // The SDK does not observe AVAudioSession interruptions or route changes — + // the host owns that policy and drives pauseForInterruption() / + // resumeAfterInterruption() explicitly. } private static let logger = OSLog( @@ -893,10 +844,6 @@ final public class PlayolaStationPlayer: ObservableObject { isSuspended = true schedulingTask?.cancel() schedulingTask = nil - // Symmetry with stop(): playTask is currently never assigned a live task, - // but cancel it anyway so pause keeps fencing it if that ever changes. - playTask?.cancel() - playTask = nil for player in _spinPlayers { player.stop() } for (_, downloadId) in activeDownloadIds { _ = fileDownloadManager.cancelDownload(id: downloadId) @@ -909,11 +856,11 @@ final public class PlayolaStationPlayer: ObservableObject { } } - /// Host-driven resume. Re-activates the session via the manager seam (no-op - /// in .hostOwned — the host must have activated already), restarts the engine, - /// and replays the interrupted station re-synced to the current wall clock. - /// Throws so the host coordinator can render failures. No-op if nothing was - /// interrupted OR if playback wasn't active when the pause happened. + /// Host-driven resume. Restarts the engine and replays the interrupted station + /// re-synced to the current wall clock. The host owns the `AVAudioSession` and + /// MUST have re-activated it before calling this. Throws so the host + /// coordinator can render failures. No-op if nothing was interrupted OR if + /// playback wasn't active when the pause happened. public func resumeAfterInterruption() async throws { guard let stationToResume = interruptedStationId, wasPlayingBeforeInterruption @@ -923,136 +870,14 @@ final public class PlayolaStationPlayer: ObservableObject { interruptedStationId = nil wasPlayingBeforeInterruption = false } - try await mainMixer.audioSessionManager.activate() // restartEngine() before play(): after an interruption the engine may be in // a stopped-but-stale CoreAudio state; play()'s lazy engine start does not - // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is the - // existing legacy-resume behavior and is idempotent when healthy. + // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is + // idempotent when healthy. try await mainMixer.restartEngine() try await play(stationId: stationToResume) } - #if os(iOS) || os(tvOS) - // `object: nil` is intentional and name-scoped: it removes only these two - // named registrations, not the AVAudioEngineConfigurationChange observer. - private func removeAudioSessionObservers() { - NotificationCenter.default.removeObserver( - self, name: AVAudioSession.interruptionNotification, object: nil) - NotificationCenter.default.removeObserver( - self, name: AVAudioSession.routeChangeNotification, object: nil) - } - - @objc public func handleAudioRouteChange(_ notification: Notification) { - guard handlesSessionEventsInternally else { return } // host owns policy - guard let userInfo = notification.userInfo, - let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, - let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) - else { - return - } - - switch reason { - case .newDeviceAvailable: - os_log("New audio route device available", log: PlayolaStationPlayer.logger, type: .info) - - case .oldDeviceUnavailable: - os_log("Audio route device disconnected", log: PlayolaStationPlayer.logger, type: .info) - guard - let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] - as? AVAudioSessionRouteDescription - else { return } - - let wasUsingHeadphones = previousRoute.outputs.contains { - [.headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE].contains($0.portType) - } - - if wasUsingHeadphones && isPlaying { - os_log( - "Headphones disconnected while playing - pausing", log: PlayolaStationPlayer.logger, - type: .info) - interruptedStationId = stationId - wasPlayingBeforeInterruption = true - stop() - } - - default: - os_log( - "Audio route changed for reason: %d", log: PlayolaStationPlayer.logger, type: .info, - reasonValue) - } - } - - @objc public func handleAudioSessionInterruption(_ notification: Notification) { - guard handlesSessionEventsInternally else { return } // host owns policy - guard let userInfo = notification.userInfo, - let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue) - else { - return - } - - switch type { - case .began: - os_log( - "Audio session interrupted - suspending", log: PlayolaStationPlayer.logger, type: .info) - isSuspended = true - wasPlayingBeforeInterruption = isPlaying - interruptedStationId = stationId - - // Cancel scheduling to prevent grabbing audio back from other apps - schedulingTask?.cancel() - schedulingTask = nil - - case .ended: - os_log("Audio session interruption ended", log: PlayolaStationPlayer.logger, type: .info) - isSuspended = false - - guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { - return - } - let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - - if options.contains(.shouldResume) && wasPlayingBeforeInterruption { - resumeAfterInterruptionFromNotification() - } - - @unknown default: - os_log( - "Unknown audio session interruption type: %d", log: PlayolaStationPlayer.logger, - type: .error, typeValue) - } - } - - @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { - os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) - - // The auto-resume below is interruption POLICY, not engine bookkeeping — - // in host mode the app owns that policy and drives resume explicitly, - // and an unguarded auto-resume here could race a host-initiated - // resumeAfterInterruption() (double play()). - guard handlesSessionEventsInternally else { return } - - guard !isSuspended else { - os_log( - "Ignoring config change while suspended", log: PlayolaStationPlayer.logger, type: .info) - return - } - - if wasPlayingBeforeInterruption { - resumeAfterInterruptionFromNotification() - } - } - - private func resumeAfterInterruptionFromNotification() { - Task { @MainActor in - do { try await resumeAfterInterruption() } catch { - await errorReporter.reportError( - error, context: "Failed to resume playback after interruption", level: .error) - } - } - } - #endif - deinit { fileDownloadManager.cancelAllDownloads() } @@ -1112,4 +937,3 @@ public protocol PlayolaStationPlayerDelegate: AnyObject { _ player: PlayolaStationPlayer, playerStateDidChange state: PlayolaStationPlayer.State) } -// swiftlint:enable file_length diff --git a/Sources/PlayolaPlayer/Player/SpinPlayer.swift b/Sources/PlayolaPlayer/Player/SpinPlayer.swift index 652120b..aea369e 100644 --- a/Sources/PlayolaPlayer/Player/SpinPlayer.swift +++ b/Sources/PlayolaPlayer/Player/SpinPlayer.swift @@ -205,8 +205,10 @@ public class SpinPlayer { fileDownloadManager ?? FileDownloadManagerAsync.shared self.delegate = delegate - // Use the centralized audio session management instead of configuring here - playolaMainMixer.configureAudioSession() + // The SDK does not own the AVAudioSession. The host app must configure and + // activate it before playback (see README "Audio session"). The engine is + // started lazily at play time; engine.start() throws if the session is not + // active, surfacing through the normal error path. /// Make connections engine.attach(playerNode) @@ -434,17 +436,12 @@ public class SpinPlayer { ) // Record that we're starting mid-file so fades can be shifted appropriately self.playbackStartOffset = from - // Callers must await ensureAudioSessionConfigured() before calling playNow. - // The fire-and-forget fallback avoids a crash but may still race. - assert( - playolaMainMixer.audioSessionManager.isConfigured, - "Audio session must be configured before calling playNow — call ensureAudioSessionConfigured() first" - ) - playolaMainMixer.configureAudioSession() do { // Start the engine off the main thread (AUIOClient_StartIO blocks the - // caller on cold hardware init). No-op if already running. + // caller on cold hardware init). No-op if already running. The host owns + // the AVAudioSession and must have activated it; if not, start() throws + // and is handled below. if !engine.isRunning { try await playolaMainMixer.start() } @@ -623,14 +620,14 @@ public class SpinPlayer { await self.loadFile(with: localUrl) - // Ensure audio session is configured and engine is started before playback. - // Starting the engine here (off-main via playolaMainMixer.start) avoids the - // AUIOClient_StartIO main-thread hang on cold first-play. + // Start the engine before playback. Starting off-main (via + // playolaMainMixer.start) avoids the AUIOClient_StartIO main-thread hang + // on cold first-play. The host must have activated the AVAudioSession; + // if not, start() throws and playback is skipped. do { - try await self.playolaMainMixer.ensureAudioSessionConfigured() try await self.playolaMainMixer.start() } catch { - // ensureAudioSessionConfigured / start already reported to Sentry; skip playback + // start() already reported to Sentry; skip playback self.clear() continuation.resume(returning: .failure(error)) return @@ -778,7 +775,6 @@ public class SpinPlayer { private func ensureEngineRunning() { guard !engine.isRunning else { return } - playolaMainMixer.configureAudioSession() // Start off the main thread (fire-and-forget) so we never block on // AUIOClient_StartIO here. Installing the tap below doesn't require the // engine to already be running — buffers flow once the async start lands. @@ -786,6 +782,7 @@ public class SpinPlayer { // `startTapInstalled = true` and installTap would let a clear() interleave // and orphan the tap. This path is only hit if the engine was reset (e.g. an // audio-session interruption) after playNow/schedulePlay started it off-main. + // The host owns the AVAudioSession; if inactive, start() throws and is logged. Task { [weak self] in guard let self else { return } do { @@ -1052,13 +1049,9 @@ public class SpinPlayer { ISO8601DateFormatter().string(from: scheduledDate) ) - // Callers must await ensureAudioSessionConfigured() before calling schedulePlay. - assert( - playolaMainMixer.audioSessionManager.isConfigured, - "Audio session must be configured before calling schedulePlay — call ensureAudioSessionConfigured() first" - ) - playolaMainMixer.configureAudioSession() // Start the engine off the main thread to avoid blocking on AUIOClient_StartIO. + // The host owns the AVAudioSession and must have activated it before the + // scheduled airtime. If not, start() throws and is reported. if !engine.isRunning { try await playolaMainMixer.start() } diff --git a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift b/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift deleted file mode 100644 index 88d633e..0000000 --- a/Tests/PlayolaPlayerTests/AudioSessionOwnershipTests.swift +++ /dev/null @@ -1,221 +0,0 @@ -// AudioSessionOwnershipTests.swift -// PlayolaPlayer - -import AVFAudio -import Foundation -import Testing - -@testable import PlayolaPlayer - -final class SpyAudioSessionManager: AudioSessionManaging { - var isConfigured: Bool { true } // simulates host mode / already-configured - private(set) var configureCount = 0 - func configureForPlayback() async throws { configureCount += 1 } - func activate() async throws {} - func deactivate() async throws {} -} - -/// Headless CI macOS runners hang when a real AVAudioEngine graph is -/// constructed (no audio HAL session) — the same failure class as the -/// SpinPlayer CI hang fixed in e8604a5. The tests in this suite construct -/// PlayolaMainMixer (directly or injected into a station player), so the -/// suite runs only where CoreAudio is available (local dev machines). -private var coreAudioGraphAvailable: Bool { - ProcessInfo.processInfo.environment["CI"] == nil -} - -@MainActor -@Suite(.enabled(if: coreAudioGraphAvailable)) -struct AudioSessionOwnershipTests { - @Test("NoOp manager is inert and reports isConfigured = true in host mode") - func noOpManagerIsInertAndReportsConfigured() async throws { - let manager = NoOpAudioSessionManager() - #expect(manager.isConfigured == true) // SDK guards/asserts must pass in host mode - try await manager.configureForPlayback() - try await manager.activate() - try await manager.deactivate() - // Inert by construction: bodies are empty. The seam invariant test below - // guarantees no other SDK code can reach AVAudioSession around this manager. - } - - @Test("Mixer accepts an injected session manager") - func mixerAcceptsInjectedManager() { - let mixer = PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager()) - #expect(mixer.audioSessionManager is NoOpAudioSessionManager) - } - - @Test("applyOwnership is idempotent for repeated same-value calls") - func applyOwnershipIsIdempotentForSameValue() { - let mixer = PlayolaMainMixer() // fresh instance — never .shared in tests - mixer.applyOwnership(.hostOwned) - mixer.applyOwnership(.hostOwned) // must not trap - #expect(mixer.audioSessionManager is NoOpAudioSessionManager) - } - - @Test("Default manager is the real AudioSessionManager (sdkOwned)") - func applyOwnershipDefaultsToSdkOwned() { - let mixer = PlayolaMainMixer() - #expect(mixer.audioSessionManager is AudioSessionManager) - } - - @Test("configureAudioSession is a no-op when the manager reports configured") - func configureIsNoOpWhenManagerReportsConfigured() async { - let spy = SpyAudioSessionManager() - let mixer = PlayolaMainMixer(audioSessionManager: spy) - mixer.configureAudioSession() - await Task.yield() // let any (incorrectly) spawned Task run - #expect(spy.configureCount == 0) - } - - // MARK: - PlayolaStationPlayer ownership wiring - - @Test("Host mode disables internal session-event handling; default keeps it") - func hostModeDisablesInternalSessionHandling() { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - #expect(player.handlesSessionEventsInternally == false) - - let legacy = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - legacy.configure(authProvider: MockAuthProvider()) // default .sdkOwned - #expect(legacy.handlesSessionEventsInternally == true) - } - - @Test("Conflicting re-configure keeps player ownership consistent with mixer") - func conflictingReconfigureStaysConsistent() { - // Only the same-value re-configure path is exercised: a conflicting value - // would trip applyOwnership's assertionFailure in debug test builds (the - // conflict path stays a documented debug tripwire, like sessionTouched). - // configure mirrors mixer.appliedOwnership, so even a release-mode - // conflicting re-configure cannot reopen the notification-handler gate. - let mixer = PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager()) - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: mixer) - - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - #expect(player.handlesSessionEventsInternally == false) - #expect(mixer.appliedOwnership == .hostOwned) - - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - #expect(player.handlesSessionEventsInternally == false) - #expect(mixer.appliedOwnership == .hostOwned) - } - - #if os(iOS) || os(tvOS) - @Test("Host mode ignores interruption notifications") - func hostModeIgnoresInterruptionNotifications() { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - player.setStateForTesting(.playing(.mock), stationId: "station-1") - player.schedulingTask = Task {} // sentinel: must survive the notification - - let note = Notification( - name: AVAudioSession.interruptionNotification, object: nil, - userInfo: [ - AVAudioSessionInterruptionTypeKey: - AVAudioSession.InterruptionType.began.rawValue - ]) - player.handleAudioSessionInterruption(note) - - #expect(player.isSuspendedForTesting == false) - #expect(player.interruptedStationIdForTesting == nil) - #expect(player.schedulingTask?.isCancelled == false) - } - #endif - - @Test("pauseForInterruption bumps generation, publishes .paused, keeps station id") - func pauseForInterruptionBumpsGenerationPublishesPausedAndKeepsStationId() { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - player.setStateForTesting(.playing(.mock), stationId: "station-1") - let generationBefore = player.playGeneration - - player.pauseForInterruption() - - #expect(player.playGeneration == generationBefore + 1) - #expect(player.stationId == "station-1") - #expect(player.isCurrentGeneration(generationBefore) == false) - if case .paused(let spin) = player.state { - #expect(spin.id == Spin.mock.id) - } else { - Issue.record("pause must publish .paused, got \(player.state)") - } - } - - @Test("resumeAfterInterruption without prior pause is a no-op") - func resumeWithoutPriorPauseIsNoOp() async throws { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - try await player.resumeAfterInterruption() - #expect(player.isPlaying == false) - #expect(player.interruptedStationIdForTesting == nil) - } - - @Test("pause while not playing does not arm resume") - func pauseWhileNotPlayingDoesNotArmResume() async throws { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - player.setStateForTesting(.idle, stationId: "station-1") - - player.pauseForInterruption() - try await player.resumeAfterInterruption() - - #expect(player.isPlaying == false) - #expect(player.interruptedStationIdForTesting == nil) - } - - @Test("Double pause keeps resume armed") - func doublePauseKeepsResumeArmed() { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - player.setStateForTesting(.playing(.mock), stationId: "station-1") - - player.pauseForInterruption() - player.pauseForInterruption() - - #expect(player.isSuspendedForTesting == true) - #expect(player.interruptedStationIdForTesting == "station-1") - #expect(player.wasPlayingBeforeInterruptionForTesting == true) - } - - @Test("Pause during loading arms resume and clears the spinner state") - func pauseDuringLoadingArmsResumeAndClearsSpinner() { - let player = PlayolaStationPlayer( - fileDownloadManager: MockFileDownloadManager(), - urlSession: MockURLSession(), - mainMixer: PlayolaMainMixer(audioSessionManager: NoOpAudioSessionManager())) - player.configure(authProvider: MockAuthProvider(), audioSessionOwnership: .hostOwned) - player.setStateForTesting(.loading(0.5), stationId: "station-1") - - player.pauseForInterruption() - - if case .idle = player.state { - } else { - Issue.record("pause during loading must clear the spinner, got \(player.state)") - } - #expect(player.wasPlayingBeforeInterruptionForTesting == true) - #expect(player.interruptedStationIdForTesting == "station-1") - } -} diff --git a/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift new file mode 100644 index 0000000..3708db0 --- /dev/null +++ b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift @@ -0,0 +1,93 @@ +// InterruptionTransportTests.swift +// PlayolaPlayer +// +// Host-driven interruption transport: pauseForInterruption() / +// resumeAfterInterruption(). The SDK does not own the AVAudioSession or observe +// interruptions; the host calls these explicitly. None of these tests touch the +// shared CoreAudio graph: constructing a station player resolves the mixer +// lazily, and the resume tests exercise only the unarmed early-return path +// (the armed path would start a real engine). + +import Foundation +import Testing + +@testable import PlayolaPlayer + +@MainActor +struct InterruptionTransportTests { + @Test("pauseForInterruption bumps generation, publishes .paused, keeps station id") + func pauseBumpsGenerationPublishesPausedAndKeepsStationId() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + let generationBefore = player.playGeneration + + player.pauseForInterruption() + + #expect(player.playGeneration == generationBefore + 1) + #expect(player.stationId == "station-1") + #expect(player.isCurrentGeneration(generationBefore) == false) + if case .paused(let spin) = player.state { + #expect(spin.id == Spin.mock.id) + } else { + Issue.record("pause must publish .paused, got \(player.state)") + } + } + + @Test("resumeAfterInterruption without prior pause is a no-op") + func resumeWithoutPriorPauseIsNoOp() async throws { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + try await player.resumeAfterInterruption() + #expect(player.isPlaying == false) + #expect(player.interruptedStationIdForTesting == nil) + } + + @Test("pause while not playing does not arm resume") + func pauseWhileNotPlayingDoesNotArmResume() async throws { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.idle, stationId: "station-1") + + player.pauseForInterruption() + try await player.resumeAfterInterruption() + + #expect(player.isPlaying == false) + #expect(player.interruptedStationIdForTesting == nil) + } + + @Test("Double pause keeps resume armed") + func doublePauseKeepsResumeArmed() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + + player.pauseForInterruption() + player.pauseForInterruption() + + #expect(player.isSuspendedForTesting == true) + #expect(player.interruptedStationIdForTesting == "station-1") + #expect(player.wasPlayingBeforeInterruptionForTesting == true) + } + + @Test("Pause during loading arms resume and clears the spinner state") + func pauseDuringLoadingArmsResumeAndClearsSpinner() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.loading(0.5), stationId: "station-1") + + player.pauseForInterruption() + + if case .idle = player.state { + } else { + Issue.record("pause during loading must clear the spinner, got \(player.state)") + } + #expect(player.wasPlayingBeforeInterruptionForTesting == true) + #expect(player.interruptedStationIdForTesting == "station-1") + } +} diff --git a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift index d5d9164..3d84c2f 100644 --- a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift +++ b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift @@ -1,16 +1,17 @@ // SessionSeamInvariantTests.swift // PlayolaPlayer // -// Structural guard: the ONLY file in the SDK allowed to reference -// AVAudioSession.sharedInstance is AudioSessionManager.swift. This is what makes -// "swap the manager" equivalent to "SDK never touches the session". +// Structural guard: the SDK is host-session-owned — it must NEVER touch the +// process-global AVAudioSession. No source file may reference +// AVAudioSession.sharedInstance, .setCategory(, or .setActive(. The host app +// owns session configuration, activation, and interruption/route policy. import Foundation import Testing struct SessionSeamInvariantTests { - @Test("Only AudioSessionManager.swift references AVAudioSession session-mutating APIs") - func onlyAudioSessionManagerTouchesSharedSession() throws { + @Test("No SDK source touches the process-global AVAudioSession") + func sdkNeverTouchesSharedSession() throws { let thisFile = URL(fileURLWithPath: #filePath) let repoRoot = thisFile // Tests/PlayolaPlayerTests/ -> repo root @@ -29,7 +30,6 @@ struct SessionSeamInvariantTests { } var offenders: [String] = [] for case let url as URL in enumerator where url.pathExtension == "swift" { - guard url.lastPathComponent != "AudioSessionManager.swift" else { continue } let content = try String(contentsOf: url, encoding: .utf8) // Direct access AND the two session-mutating verbs (catches alias/wrapper // bypass). Substring match: a comment mentioning ".setCategory(" would @@ -39,6 +39,6 @@ struct SessionSeamInvariantTests { offenders.append("\(url.lastPathComponent): \(needle)") } } - #expect(offenders.isEmpty, "Direct session access outside the seam: \(offenders)") + #expect(offenders.isEmpty, "SDK must not touch AVAudioSession: \(offenders)") } } From c78451fa394f9388c3b111396214901dcbb2ac17 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 19:34:12 -0500 Subject: [PATCH 14/20] fix(sdk): engine-config self-recovery + preserve resume token on failure (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review (Codex challenge) findings on the host-only rework: - [P1] resumeAfterInterruption() cleared the armed resume token in a defer even when restartEngine()/play() threw, permanently disarming retry. Now disarms only after a successful resume; a failed resume (e.g. host hasn't reactivated the session yet) stays armed for the next attempt. (This was unsafe to do under the old dual-mode config-change observer; that observer is gone, so preserving the token can no longer cause a double-resume.) - [P1] removing the engine-config observer left no recovery when AVAudioEngine stops itself on a hardware/format/route reconfiguration — silent dead audio while state stayed .playing. Re-added a PURE engine-recovery observer (AVAudioEngineConfigurationChange, not an AVAudioSession API — seam intact): restarts the engine and re-syncs to wall clock, guarded to skip while host-paused or not playing. object: nil avoids resolving the shared mixer at construction (no CI hang). This is engine ownership, not session ownership. - README: corrected the stale 'session management' architecture bullet. - Example app: demonstrates the full host contract (interruption observer driving pause/resume), not just launch-time activation. Accepted as follow-ups (noted on PR): cold-start engine-start failure is retried like a transient download failure and surfaces as .scheduleError (only reachable when the host violates the activate-before-play contract, caught at dev time); the seam test is a substring tripwire, not a complete invariant (documented in-test). --- .../PlayolaPlayerExampleApp.swift | 54 ++++++++++++++++--- README.md | 2 +- .../Player/PlayolaStationPlayer.swift | 44 +++++++++++++-- .../InterruptionTransportTests.swift | 38 +++++++++++++ 4 files changed, 124 insertions(+), 14 deletions(-) diff --git a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift index 353fe41..8eeed48 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift @@ -6,16 +6,27 @@ // import AVFoundation +import PlayolaPlayer import SwiftUI @main struct PlayolaPlayerExampleApp: App { + private let audioSession = HostAudioSession() + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +/// PlayolaPlayer does not manage the `AVAudioSession` — the host app owns it. +/// This demonstrates the full host contract: configure + activate the session, +/// and drive `pauseForInterruption()` / `resumeAfterInterruption()` from the +/// interruption notifications. A production app would also handle route changes. +@MainActor +final class HostAudioSession { init() { - // PlayolaPlayer does not manage the AVAudioSession — the host app owns it. - // Configure and activate it for long-form playback before starting a - // station. Real apps should also observe interruption/route notifications - // and drive PlayolaStationPlayer.pauseForInterruption() / - // resumeAfterInterruption(); this example keeps it minimal. do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: []) @@ -23,11 +34,38 @@ struct PlayolaPlayerExampleApp: App { } catch { print("Failed to configure AVAudioSession: \(error)") } + + NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, object: nil, queue: .main + ) { notification in + MainActor.assumeIsolated { + Self.handleInterruption(notification) + } + } } - var body: some Scene { - WindowGroup { - ContentView() + private static func handleInterruption(_ notification: Notification) { + guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + PlayolaStationPlayer.shared.pauseForInterruption() + case .ended: + guard let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt, + AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) + else { return } + Task { + do { + try AVAudioSession.sharedInstance().setActive(true) + try await PlayolaStationPlayer.shared.resumeAfterInterruption() + } catch { + print("Resume failed: \(error)") + } + } + @unknown default: + break } } } diff --git a/README.md b/README.md index e7ff560..c6aef5f 100644 --- a/README.md +++ b/README.md @@ -686,7 +686,7 @@ File management ensures smooth playback: Built on AVAudioEngine for audio processing: - Real-time mixing of multiple audio sources - Volume control and fading between tracks -- Session management for handling interruptions and route changes +- Self-recovers its own engine graph on `AVAudioEngineConfigurationChange` (the host owns the `AVAudioSession`; see [Audio session](#audio-session)) ### How Separate Files Become Continuous Radio diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index bee3702..6f23931 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -178,9 +178,23 @@ final public class PlayolaStationPlayer: ObservableObject { self.urlSession = urlSession self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) - // The SDK does not observe AVAudioSession interruptions or route changes — + + // The SDK does NOT observe AVAudioSession interruptions or route changes — // the host owns that policy and drives pauseForInterruption() / // resumeAfterInterruption() explicitly. + // + // It DOES recover its own engine graph: AVAudioEngine stops itself on a + // hardware/format/route reconfiguration and posts this notification. Only + // the SDK can observe this for its own engine, so it must self-heal — this + // is engine ownership, not session ownership, and touches no AVAudioSession + // API. `object: nil` avoids resolving the shared mixer (and building the + // CoreAudio graph) at construction time. + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioEngineConfigurationChange(_:)), + name: .AVAudioEngineConfigurationChange, + object: nil + ) } private static let logger = OSLog( @@ -866,16 +880,36 @@ final public class PlayolaStationPlayer: ObservableObject { wasPlayingBeforeInterruption else { return } isSuspended = false - defer { - interruptedStationId = nil - wasPlayingBeforeInterruption = false - } // restartEngine() before play(): after an interruption the engine may be in // a stopped-but-stale CoreAudio state; play()'s lazy engine start does not // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is // idempotent when healthy. try await mainMixer.restartEngine() try await play(stationId: stationToResume) + // Disarm only after a successful resume. If restartEngine()/play() throws + // (e.g. the host hasn't reactivated the session yet), the armed state is + // preserved so a later resumeAfterInterruption() retry still works. + interruptedStationId = nil + wasPlayingBeforeInterruption = false + } + + /// Recovers the SDK's own engine graph when AVAudioEngine reconfigures itself + /// (hardware/format/route change) and stops. This is engine ownership, not + /// session ownership: it touches no AVAudioSession API. Skipped while the host + /// has paused for an interruption (the host drives resume then). Only acts + /// while actively playing; re-syncs to the live wall clock via play(). + @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { + os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) + guard !isSuspended, isPlaying, let stationToRecover = stationId else { return } + Task { @MainActor in + do { + try await mainMixer.restartEngine() + try await play(stationId: stationToRecover) + } catch { + await errorReporter.reportError( + error, context: "Failed to recover engine after configuration change", level: .error) + } + } } deinit { diff --git a/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift index 3708db0..39ef73d 100644 --- a/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift +++ b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift @@ -8,6 +8,7 @@ // lazily, and the resume tests exercise only the unarmed early-return path // (the armed path would start a real engine). +import AVFAudio import Foundation import Testing @@ -15,6 +16,43 @@ import Testing @MainActor struct InterruptionTransportTests { + // Engine-config recovery must NOT fire while idle or host-paused (those paths + // would otherwise spin up a real engine via restartEngine). Only the guarded + // skip paths are unit-tested; the active recovery path needs CoreAudio. + @Test("Engine configuration change is ignored while idle") + func engineConfigChangeIgnoredWhileIdle() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.idle, stationId: nil) + + player.handleAudioEngineConfigurationChange( + Notification(name: .AVAudioEngineConfigurationChange)) + + if case .idle = player.state { + } else { + Issue.record("config change while idle must not change state, got \(player.state)") + } + } + + @Test("Engine configuration change is ignored while host-paused") + func engineConfigChangeIgnoredWhilePaused() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + player.pauseForInterruption() // sets isSuspended; state -> .paused + + player.handleAudioEngineConfigurationChange( + Notification(name: .AVAudioEngineConfigurationChange)) + + #expect(player.isSuspendedForTesting == true) + if case .paused = player.state { + } else { + Issue.record("config change while paused must not change state, got \(player.state)") + } + } + @Test("pauseForInterruption bumps generation, publishes .paused, keeps station id") func pauseBumpsGenerationPublishesPausedAndKeepsStationId() { let player = PlayolaStationPlayer( From 0a780c8d68a086c73c65076fac43f400fc30fc4c Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Tue, 9 Jun 2026 19:50:54 -0500 Subject: [PATCH 15/20] fix(example): retain interruption observer token (review) --- .../PlayolaPlayerExampleApp.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift index 8eeed48..945b809 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift @@ -26,6 +26,10 @@ struct PlayolaPlayerExampleApp: App { /// interruption notifications. A production app would also handle route changes. @MainActor final class HostAudioSession { + /// Retained so the block observer can be removed in deinit and its lifetime + /// is explicit (NotificationCenter holds the token until removal). + private var interruptionObserver: (any NSObjectProtocol)? + init() { do { let session = AVAudioSession.sharedInstance() @@ -35,7 +39,7 @@ final class HostAudioSession { print("Failed to configure AVAudioSession: \(error)") } - NotificationCenter.default.addObserver( + interruptionObserver = NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { notification in MainActor.assumeIsolated { @@ -44,6 +48,12 @@ final class HostAudioSession { } } + deinit { + if let interruptionObserver { + NotificationCenter.default.removeObserver(interruptionObserver) + } + } + private static func handleInterruption(_ notification: Notification) { guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) From 8d3baa8b9973388dc8b85f69c334661c0def2bfd Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 10 Jun 2026 11:14:49 -0500 Subject: [PATCH 16/20] docs(sdk): 0.20.0 changelog/migration; scope engine-config observer to own engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CHANGELOG 0.20.0 entry (pre-1.0 breaking = minor bump) documenting the host-owns-AVAudioSession change + migration steps, in the project's style. - Scope the AVAudioEngineConfigurationChange observer to the SDK's OWN engine (registered lazily on first playback) instead of object: nil. Fixes the Greptile 4/5 concern: a host running its own AVAudioEngine no longer triggers spurious SDK restartEngine()+play() on unrelated config changes. Lazy registration keeps construction from building the CoreAudio graph (no CI hang). - README migration guide: drop the phantom 'audioSessionOwnership parameter' step (that param never shipped in a release) — configure() is otherwise unchanged. --- CHANGELOG.md | 58 ++++++++++++++++++- README.md | 2 +- .../Player/PlayolaStationPlayer.swift | 37 ++++++++---- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b92dc9..8065f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,63 @@ All notable changes to PlayolaPlayer are documented here. This project follows [Semantic Versioning](https://semver.org/). Versions correspond to git tags, -which Swift Package Manager consumers pin to. +which Swift Package Manager consumers pin to. Pre-1.0, breaking changes bump the +minor version. + +## 0.20.0 + +This release makes the **host app the sole owner of the `AVAudioSession`.** The +SDK previously configured/activated the session and self-handled interruptions +and route changes; it no longer touches `AVAudioSession` at all. This lets the +SDK coexist cleanly with other audio subsystems in your app (URL streaming, +recording, VoIP) instead of fighting them for the process-global session. + +### Removed + +- **The SDK no longer manages the `AVAudioSession`.** `AudioSessionManager` is + gone, `PlayolaMainMixer` no longer exposes `configureAudioSession()` / + `ensureAudioSessionConfigured()` / `deactivateAudioSession()`, and + `PlayolaStationPlayer` no longer registers `AVAudioSession.interruptionNotification` + / `routeChangeNotification` observers. The `handleAudioSessionInterruption(_:)` + and `handleAudioRouteChange(_:)` methods are removed. + + **Source-breaking — required host changes:** + + 1. **Own the session.** Configure and activate it before `play(stationId:)`: + `try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])` + then `setActive(true)`. The SDK no longer does this; without it, + `play(stationId:)` fails when the engine starts (surfaced as `.error`). + + 2. **Drive interruptions yourself.** Observe `AVAudioSession.interruptionNotification` + (and route changes as needed) and call the new `pauseForInterruption()` / + `resumeAfterInterruption()` (below). Reactivate the session before calling + resume. + + See the [Audio session](README.md#audio-session) and migration sections of the + README for copy-paste host setup and a full interruption-handling example. + +### Added + +- **Host-driven interruption transport.** + - `PlayolaStationPlayer.pauseForInterruption()` — silences playback, + cancels scheduling/downloads, and is generation-fenced so in-flight work + from before the pause can't publish state afterward. Preserves only the + station identity (wall-clock radio has no frozen position). Idempotent + across a double-pause. + - `PlayolaStationPlayer.resumeAfterInterruption() async throws` — restarts the + engine and replays the interrupted station re-synced to the live wall clock. + Disarms only on success, so a failed resume (e.g. the host hasn't + reactivated the session yet) stays retryable. + +- **`PlayolaStationPlayer.State` gained a `.paused(Spin)` case.** Published while + paused for an interruption; the `Spin` carries display metadata (title, + artist, artwork URL) for the track that was playing. **Source-breaking for + consumers** that `switch` exhaustively over `State`: add a `case .paused` arm. + +- **Engine self-recovery.** The SDK now restarts and re-syncs its own engine + graph on `AVAudioEngineConfigurationChange` (scoped to the SDK's own engine, + so a host running a separate `AVAudioEngine` is unaffected). This is engine + ownership, not session ownership — it touches no `AVAudioSession` API. ## 0.19.0 diff --git a/README.md b/README.md index c6aef5f..2f9080c 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ PlayolaStationPlayer.shared.$state 3. **Handle `.paused`.** A `.paused(Spin)` state was added to `PlayolaStationPlayer.State`. Any exhaustive `switch` over the state must add a `.paused` case (the compiler will flag every site). -4. **`configure(…)` lost its `audioSessionOwnership` parameter.** Host ownership is now the only mode; remove the argument if you passed it. +`configure(authProvider:baseURL:)` is otherwise unchanged. ### File Download Management diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 6f23931..fdcea72 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -178,22 +178,36 @@ final public class PlayolaStationPlayer: ObservableObject { self.urlSession = urlSession self.authProvider = nil self.listeningSessionReporter = ListeningSessionReporter(stationPlayer: self, authProvider: nil) - // The SDK does NOT observe AVAudioSession interruptions or route changes — // the host owns that policy and drives pauseForInterruption() / - // resumeAfterInterruption() explicitly. - // - // It DOES recover its own engine graph: AVAudioEngine stops itself on a - // hardware/format/route reconfiguration and posts this notification. Only - // the SDK can observe this for its own engine, so it must self-heal — this - // is engine ownership, not session ownership, and touches no AVAudioSession - // API. `object: nil` avoids resolving the shared mixer (and building the - // CoreAudio graph) at construction time. + // resumeAfterInterruption() explicitly. It DOES self-recover its own engine + // graph; that observer is registered lazily once playback begins (see + // registerEngineConfigObserverIfNeeded()) so construction stays cheap. + } + + /// True once the engine-config-change observer has been registered (lazily, + /// on first playback) so we register it exactly once. + private var engineConfigObserverRegistered = false + + /// Registers the AVAudioEngineConfigurationChange observer, scoped to the + /// SDK's OWN engine. AVAudioEngine stops itself on a hardware/format/route + /// reconfiguration and posts this notification; only the SDK can observe it + /// for its own engine, so it must self-heal. This is engine ownership, not + /// session ownership — it touches no AVAudioSession API. + /// + /// Registered lazily (from getAvailableSpinPlayer, at the moment a SpinPlayer + /// resolves the shared mixer anyway) rather than at init, so merely + /// constructing a player never builds the CoreAudio graph. Scoping to + /// `mainMixer.engine` (not `object: nil`) means a host running its own + /// AVAudioEngine does NOT trigger spurious SDK restarts. + private func registerEngineConfigObserverIfNeeded() { + guard !engineConfigObserverRegistered else { return } + engineConfigObserverRegistered = true NotificationCenter.default.addObserver( self, selector: #selector(handleAudioEngineConfigurationChange(_:)), name: .AVAudioEngineConfigurationChange, - object: nil + object: mainMixer.engine ) } @@ -205,6 +219,9 @@ final public class PlayolaStationPlayer: ObservableObject { let availablePlayers = _spinPlayers.filter({ $0.state == .available }) if let available = availablePlayers.first { return available } + // First real playback: the SpinPlayer below resolves the shared mixer, so + // it's safe to scope the engine-recovery observer to that engine now. + registerEngineConfigObserverIfNeeded() // Note: SpinPlayer currently couples to PlayolaMainMixer.shared internally // (it ignores an injected mixer). Fine in production where everything uses // .shared; threading injection through SpinPlayer is a known follow-up. From 4db98b5946d11592b8202c61fd65390bb0faf5bb Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 10 Jun 2026 16:36:45 -0500 Subject: [PATCH 17/20] fix(sdk): one-shot retryable resume; stop() clears interruption state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile (PR #99) caught that the 'preserve token on failure' behavior was defeated by play(), which resets the armed interruption fields at its very first lines — a transient schedule-fetch failure during resume silently killed the host's retry path. Codex adversarial review then showed a re-arm-on-failure approach has races (reviving a station the host stop()'d mid-resume; same-station host restart). Rather than keep patching the re-arm, the resume is redesigned as a clean one-shot: - resumeAfterInterruption() consumes the armed interruption up-front and is a single attempt. It throws on failure (host knows — not silent); retry is via play(stationId:), documented. No re-arm, so no re-arm races. - A generation-supersession guard after restartEngine() abandons the resume if a host stop()/play() interleaved during it (so it can't revive a stopped station or override a freshly-started one). Supersession during play()'s own awaits is handled by play()'s existing generation gate. - stop() now clears isSuspended / wasPlayingBeforeInterruption / interruptedStationId ('stop means forget everything, including any armed interruption'). Also made handleAudioEngineConfigurationChange internal (was public) — @objc selector target, not host API. Tests: stop()-clears-armed-state and resume-after-stop-is-no-op (CoreAudio-free). The armed-resume path itself calls restartEngine() (real CoreAudio) so stays inspection-verified per the no-CoreAudio test rule. The cross-repo .paused compile break Greptile flagged is intentional lockstep coordination (app adopts .paused in Phase 1), not an SDK change. --- .../Player/PlayolaStationPlayer.swift | 40 ++++++++++++++----- .../InterruptionTransportTests.swift | 31 ++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index fdcea72..19912b9 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -4,6 +4,7 @@ // // Created by Brian D Keane on 12/29/24. // +// swiftlint:disable file_length import AVFAudio import Combine import Foundation @@ -847,6 +848,12 @@ final public class PlayolaStationPlayer: ObservableObject { self.stationId = nil self.currentSchedule = nil self.state = .idle + // stop() means "forget everything", including any armed interruption. Without + // this, a later resumeAfterInterruption() (or a resume retry) could revive a + // station the host explicitly stopped. + self.isSuspended = false + self.wasPlayingBeforeInterruption = false + self.interruptedStationId = nil os_log("✅ STOP completed", log: PlayolaStationPlayer.logger, type: .info) } @@ -889,25 +896,36 @@ final public class PlayolaStationPlayer: ObservableObject { /// Host-driven resume. Restarts the engine and replays the interrupted station /// re-synced to the current wall clock. The host owns the `AVAudioSession` and - /// MUST have re-activated it before calling this. Throws so the host - /// coordinator can render failures. No-op if nothing was interrupted OR if - /// playback wasn't active when the pause happened. + /// MUST have re-activated it before calling this. No-op if nothing was + /// interrupted OR if playback wasn't active when the pause happened. + /// + /// Consumes the interruption up-front: this is a one-shot attempt. It throws + /// on failure so the host knows; **to retry, call `play(stationId:)`** with the + /// station you were resuming (the host holds it). The resume is abandoned + /// cleanly if the host starts or stops playback while it's in flight. public func resumeAfterInterruption() async throws { guard let stationToResume = interruptedStationId, wasPlayingBeforeInterruption else { return } + // Consume the armed interruption now — this attempt owns it. (play() would + // clear these at its first lines anyway; clearing here keeps the failure + // path honest: retry is via play(), not a second resume call.) isSuspended = false + interruptedStationId = nil + wasPlayingBeforeInterruption = false + // restartEngine() before play(): after an interruption the engine may be in // a stopped-but-stale CoreAudio state; play()'s lazy engine start does not // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is // idempotent when healthy. + let generation = playGeneration try await mainMixer.restartEngine() + // A host stop()/play() during restartEngine bumped the generation — abandon + // the resume so we don't revive a station the host stopped or override a + // station it just started. (Supersession during play()'s own awaits is + // handled by play()'s generation gate.) + guard generation == playGeneration else { return } try await play(stationId: stationToResume) - // Disarm only after a successful resume. If restartEngine()/play() throws - // (e.g. the host hasn't reactivated the session yet), the armed state is - // preserved so a later resumeAfterInterruption() retry still works. - interruptedStationId = nil - wasPlayingBeforeInterruption = false } /// Recovers the SDK's own engine graph when AVAudioEngine reconfigures itself @@ -915,7 +933,10 @@ final public class PlayolaStationPlayer: ObservableObject { /// session ownership: it touches no AVAudioSession API. Skipped while the host /// has paused for an interruption (the host drives resume then). Only acts /// while actively playing; re-syncs to the live wall clock via play(). - @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { + /// + /// Internal (not public): it's a notification selector target, not host API — + /// the host never calls it. `@objc` is required for `#selector`. + @objc func handleAudioEngineConfigurationChange(_ notification: Notification) { os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) guard !isSuspended, isPlaying, let stationToRecover = stationId else { return } Task { @MainActor in @@ -988,3 +1009,4 @@ public protocol PlayolaStationPlayerDelegate: AnyObject { _ player: PlayolaStationPlayer, playerStateDidChange state: PlayolaStationPlayer.State) } +// swiftlint:enable file_length diff --git a/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift index 39ef73d..4c3b0b6 100644 --- a/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift +++ b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift @@ -112,6 +112,37 @@ struct InterruptionTransportTests { #expect(player.wasPlayingBeforeInterruptionForTesting == true) } + @Test("stop() clears armed interruption state so a later resume can't revive it") + func stopClearsArmedInterruptionState() { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + player.pauseForInterruption() + #expect(player.interruptedStationIdForTesting == "station-1") // armed + + player.stop() + + #expect(player.interruptedStationIdForTesting == nil) + #expect(player.wasPlayingBeforeInterruptionForTesting == false) + #expect(player.isSuspendedForTesting == false) + } + + @Test("resumeAfterInterruption after a stop is a no-op") + func resumeAfterStopIsNoOp() async throws { + let player = PlayolaStationPlayer( + fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession()) + player.configure(authProvider: MockAuthProvider()) + player.setStateForTesting(.playing(.mock), stationId: "station-1") + player.pauseForInterruption() + player.stop() + + // Guard fails (stop cleared the armed fields) → returns before touching the + // engine, so this never resolves the shared mixer / starts CoreAudio. + try await player.resumeAfterInterruption() + #expect(player.isPlaying == false) + } + @Test("Pause during loading arms resume and clears the spinner state") func pauseDuringLoadingArmsResumeAndClearsSpinner() { let player = PlayolaStationPlayer( From 6a077a11b820bc59dbe9110b22ae6cfa5febe470 Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 10 Jun 2026 17:22:55 -0500 Subject: [PATCH 18/20] fix(sdk): publish .error on resume engine-restart failure; clarify retry doc (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #100 review (Greptile P2s): - On restartEngine() failure during resume, publish state = .error(...) (guarded by the supersession check) so a host driving UI from $state isn't left on a stale .paused with no working resume — mirrors play()'s own error path. - Doc: clarify that the station for a retry play() is available as the player's stationId (still set after a failed resume; only stop() clears it), rather than implying the host must cache it externally. --- .../Player/PlayolaStationPlayer.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 19912b9..7fe5929 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -899,10 +899,12 @@ final public class PlayolaStationPlayer: ObservableObject { /// MUST have re-activated it before calling this. No-op if nothing was /// interrupted OR if playback wasn't active when the pause happened. /// - /// Consumes the interruption up-front: this is a one-shot attempt. It throws - /// on failure so the host knows; **to retry, call `play(stationId:)`** with the - /// station you were resuming (the host holds it). The resume is abandoned - /// cleanly if the host starts or stops playback while it's in flight. + /// Consumes the interruption up-front: this is a one-shot attempt. On failure + /// it both throws AND publishes `.error` state (so a host driving UI from + /// `$state` sees the failure without catching the throw). **To retry, call + /// `play(stationId:)`** — the station is available as the player's `stationId` + /// (still set after a failed resume; only `stop()` clears it). The resume is + /// abandoned cleanly if the host starts or stops playback while it's in flight. public func resumeAfterInterruption() async throws { guard let stationToResume = interruptedStationId, wasPlayingBeforeInterruption @@ -919,7 +921,18 @@ final public class PlayolaStationPlayer: ObservableObject { // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is // idempotent when healthy. let generation = playGeneration - try await mainMixer.restartEngine() + do { + try await mainMixer.restartEngine() + } catch { + // Mirror play()'s error path so a host observing $state isn't left on a + // stale .paused with no working resume — but only if a host stop()/play() + // hasn't superseded us during the restart. + if generation == playGeneration { + state = .error( + .playbackError("Failed to restart audio engine on resume: \(error.localizedDescription)")) + } + throw error + } // A host stop()/play() during restartEngine bumped the generation — abandon // the resume so we don't revive a station the host stopped or override a // station it just started. (Supersession during play()'s own awaits is From b43a63d49e6418a88f779ea148ccd2e67af9096b Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 10 Jun 2026 18:03:19 -0500 Subject: [PATCH 19/20] fix(sdk): generation-gate + .error state in engine-config recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #99 re-review (Greptile, 3 P1s): handleAudioEngineConfigurationChange's recovery Task was missing the supersession discipline that resumeAfterInterruption() already uses. Two issues, same fixes as resume: - Generation gate: snapshot playGeneration (with stationToRecover) before restartEngine(); if a host stop()/play() bumps it during the restart, abandon the recovery instead of calling play(stationToRecover) — which would override the host's explicit station switch / revive a stopped station. - State on failure: if restartEngine() throws, publish state = .error(...) (gated on generation) so a host driving UI from $state isn't left on a stale .playing with dead audio. Previously the failure only went to Sentry. The recovery path itself calls restartEngine()/play() (real CoreAudio / resolves .shared), so per the no-CoreAudio test rule it stays inspection-verified; the synchronous guard (!isSuspended/isPlaying/stationId) is covered by the existing engine-config tests. --- .../Player/PlayolaStationPlayer.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index 7fe5929..f8867d9 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -952,11 +952,34 @@ final public class PlayolaStationPlayer: ObservableObject { @objc func handleAudioEngineConfigurationChange(_ notification: Notification) { os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) guard !isSuspended, isPlaying, let stationToRecover = stationId else { return } + // Snapshot the recovery attempt: stationToRecover + generation together. Any + // host stop()/play() after this point bumps playGeneration and invalidates + // the recovery — we must not override the host's explicit choice (same + // supersession discipline resumeAfterInterruption() uses). + let generation = playGeneration Task { @MainActor in do { try await mainMixer.restartEngine() + } catch { + // Surface via state (mirrors resumeAfterInterruption) so a host driving + // UI from $state isn't left on a stale .playing with dead audio — but + // only if a host stop()/play() hasn't superseded us during the restart. + if generation == playGeneration { + state = .error( + .playbackError( + "Failed to recover audio engine after configuration change: " + + error.localizedDescription)) + } + await errorReporter.reportError( + error, context: "Failed to recover engine after configuration change", level: .error) + return + } + // A host stop()/play() during restartEngine took over — don't override it. + guard generation == playGeneration else { return } + do { try await play(stationId: stationToRecover) } catch { + // play() publishes .error itself on terminal failure when still current. await errorReporter.reportError( error, context: "Failed to recover engine after configuration change", level: .error) } From 82da3ba462d936cb62afef766f4b9ba0a202ceab Mon Sep 17 00:00:00 2001 From: Brian Keane Date: Wed, 10 Jun 2026 18:14:03 -0500 Subject: [PATCH 20/20] fix(sdk): deliver engine-config recovery on main; gate failure report on generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #101 review (Greptile P2s): - Thread-safety: AVAudioEngineConfigurationChange fires on an unspecified thread and @objc dispatch bypasses @MainActor, so the handler's synchronous @MainActor reads (isSuspended/isPlaying/stationId/playGeneration) could race. Register the observer block-based with queue: .main and assumeIsolated, so the handler runs on the main actor; drop @objc (no longer a #selector target). - Sentry noise: the restart-failure path now reports only when still current (generation == playGeneration) — a superseded recovery is moot, matching the state gate. Not addressed (tracked follow-up): a PlayolaMainMixing protocol to inject a mock mixer would unblock unit-testing the recovery Task's .error / generation-stale branches (currently inspection-verified, like all restartEngine()/play() paths). That's the same .shared-coupling follow-up already noted for SpinPlayer; it touches SpinPlayer too and is out of scope for this fix. --- .../Player/PlayolaStationPlayer.swift | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index f8867d9..102edce 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift @@ -189,6 +189,7 @@ final public class PlayolaStationPlayer: ObservableObject { /// True once the engine-config-change observer has been registered (lazily, /// on first playback) so we register it exactly once. private var engineConfigObserverRegistered = false + private var engineConfigObserver: (any NSObjectProtocol)? /// Registers the AVAudioEngineConfigurationChange observer, scoped to the /// SDK's OWN engine. AVAudioEngine stops itself on a hardware/format/route @@ -204,12 +205,19 @@ final public class PlayolaStationPlayer: ObservableObject { private func registerEngineConfigObserverIfNeeded() { guard !engineConfigObserverRegistered else { return } engineConfigObserverRegistered = true - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioEngineConfigurationChange(_:)), - name: .AVAudioEngineConfigurationChange, - object: mainMixer.engine - ) + // Deliver on the main queue: AVAudioEngineConfigurationChange fires on an + // unspecified thread, and the handler reads @MainActor state (isSuspended, + // isPlaying, stationId, playGeneration) synchronously before hopping into a + // Task. `queue: .main` guarantees those reads run on the main actor. + engineConfigObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: mainMixer.engine, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + self?.handleAudioEngineConfigurationChange(notification) + } + } } private static let logger = OSLog( @@ -947,9 +955,10 @@ final public class PlayolaStationPlayer: ObservableObject { /// has paused for an interruption (the host drives resume then). Only acts /// while actively playing; re-syncs to the live wall clock via play(). /// - /// Internal (not public): it's a notification selector target, not host API — - /// the host never calls it. `@objc` is required for `#selector`. - @objc func handleAudioEngineConfigurationChange(_ notification: Notification) { + /// Internal: delivered on the main queue from a block observer (see + /// registerEngineConfigObserverIfNeeded), so it runs on the main actor. Not + /// host API — the host never calls it. + func handleAudioEngineConfigurationChange(_ notification: Notification) { os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) guard !isSuspended, isPlaying, let stationToRecover = stationId else { return } // Snapshot the recovery attempt: stationToRecover + generation together. Any @@ -962,16 +971,17 @@ final public class PlayolaStationPlayer: ObservableObject { try await mainMixer.restartEngine() } catch { // Surface via state (mirrors resumeAfterInterruption) so a host driving - // UI from $state isn't left on a stale .playing with dead audio — but - // only if a host stop()/play() hasn't superseded us during the restart. + // UI from $state isn't left on a stale .playing with dead audio — and + // report — but only if a host stop()/play() hasn't superseded us during + // the restart (a superseded recovery is moot; don't add Sentry noise). if generation == playGeneration { state = .error( .playbackError( "Failed to recover audio engine after configuration change: " + error.localizedDescription)) + await errorReporter.reportError( + error, context: "Failed to recover engine after configuration change", level: .error) } - await errorReporter.reportError( - error, context: "Failed to recover engine after configuration change", level: .error) return } // A host stop()/play() during restartEngine took over — don't override it.