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/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/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift index 423a655..945b809 100644 --- a/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift +++ b/PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift @@ -5,13 +5,77 @@ // Created by Brian D Keane on 12/29/24. // +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 { + /// 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() + try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: []) + try session.setActive(true) + } catch { + print("Failed to configure AVAudioSession: \(error)") + } + + interruptionObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, object: nil, queue: .main + ) { notification in + MainActor.assumeIsolated { + Self.handleInterruption(notification) + } + } + } + + 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) + 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 e12f71f..2f9080c 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) } } } @@ -474,32 +494,107 @@ PlayolaErrorReporter.shared.reportingLevel = .debug // Reports everything error.playolaReport(context: "Custom operation failed", level: .warning) ``` -### Audio Session Management +### Audio session + +**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. + +The host is responsible for: + +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. + +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()`. + +Minimal launch-time setup (long-form playback, AirPlay 2 friendly): + +```swift +import AVFoundation + +let session = AVAudioSession.sharedInstance() +try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: []) +try session.setActive(true) +``` + +#### Interruption handling -PlayolaPlayer automatically manages audio sessions, but you can customize the behavior: +Call `pauseForInterruption()` synchronously when an interruption begins and `resumeAfterInterruption()` asynchronously when it ends: ```swift import AVFoundation +import PlayolaPlayer -// Handle interruptions (phone calls, alarms, etc.) +// Register in your audio coordinator's init NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { notification in - PlayolaStationPlayer.shared.handleAudioSessionInterruption(notification) -} + 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)") + } + } -// Handle route changes (headphones plugged/unplugged) -NotificationCenter.default.addObserver( - forName: AVAudioSession.routeChangeNotification, - object: nil, - queue: .main -) { notification in - PlayolaStationPlayer.shared.handleAudioRouteChange(notification) + @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) +``` + +### 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). + +`configure(authProvider:baseURL:)` is otherwise unchanged. + ### File Download Management Work with the file download and caching system: @@ -591,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 @@ -708,27 +803,32 @@ 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 + ) // Playback control public func play(stationId: String, atDate: Date? = nil) async throws public func stop() + // Interruption handling — the host calls these (it owns the AVAudioSession) + 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) - 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) } ``` @@ -771,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 48a384f..0000000 --- a/Sources/PlayolaPlayer/Player/AudioSessionManager.swift +++ /dev/null @@ -1,103 +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 } -} - -#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 8386e2f..603b943 100644 --- a/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift +++ b/Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift @@ -40,14 +40,12 @@ open class PlayolaMainMixer: NSObject { open var delegate: PlayolaMainMixerDelegate? private let errorReporter = PlayolaErrorReporter.shared - let audioSessionManager: AudioSessionManager private static let logger = OSLog(subsystem: "fm.playola.playolaCore", category: "MainMixer") override init() { self.mixerNode = AVAudioMixerNode() self.engine = AVAudioEngine() - self.audioSessionManager = AudioSessionManager() super.init() self.engine.attach(self.mixerNode) @@ -70,64 +68,6 @@ open class PlayolaMainMixer: NSObject { self.mixerNode.removeTap(onBus: 0) } - /// Configures the shared audio session for playback (fire-and-forget) - public func configureAudioSession() { - 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 { - 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 - public func deactivateAudioSession() { - 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) diff --git a/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift b/Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift index c922446..102edce 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 @@ -68,6 +68,10 @@ final public class PlayolaStationPlayer: ObservableObject { private let errorReporter = PlayolaErrorReporter.shared private var authProvider: PlayolaAuthenticationProvider? private let urlSession: URLSessionProtocol + /// Resolved lazily so merely constructing a station player (e.g. in headless + /// 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. @@ -122,6 +126,11 @@ final public class PlayolaStationPlayer: ObservableObject { /// - Parameters: /// - authProvider: Provider for JWT tokens /// - baseURL: Base URL for API endpoints. Defaults to production URL. + /// + /// 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")! @@ -140,6 +149,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 { @@ -166,29 +179,45 @@ 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 self-recover its own engine + // graph; that observer is registered lazily once playback begins (see + // registerEngineConfigObserverIfNeeded()) so construction stays cheap. + } - #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 - ) + /// 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)? - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAudioEngineConfigurationChange(_:)), - name: .AVAudioEngineConfigurationChange, - object: PlayolaMainMixer.shared.engine - ) - #endif + /// 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 + // 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( @@ -199,6 +228,12 @@ 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. let newPlayer = SpinPlayer(delegate: self) _spinPlayers.append(newPlayer) return newPlayer @@ -760,6 +795,17 @@ 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 } + var wasPlayingBeforeInterruptionForTesting: Bool { wasPlayingBeforeInterruption } + /// Stops the current playback and releases associated resources. /// /// This method: @@ -810,128 +856,145 @@ 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) } - #if os(iOS) || os(tvOS) - @objc public func handleAudioRouteChange(_ notification: Notification) { - 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) + /// 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 + // 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 } - - @objc public func handleAudioSessionInterruption(_ notification: Notification) { - 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 { - resumeAfterInterruption() - } - - @unknown default: - os_log( - "Unknown audio session interruption type: %d", log: PlayolaStationPlayer.logger, - type: .error, typeValue) - } + isSuspended = true + schedulingTask?.cancel() + schedulingTask = nil + for player in _spinPlayers { player.stop() } + for (_, downloadId) in activeDownloadIds { + _ = fileDownloadManager.cancelDownload(id: downloadId) } + activeDownloadIds.removeAll() + 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 + } + } - @objc public func handleAudioEngineConfigurationChange(_ notification: Notification) { - os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) - - guard !isSuspended else { - os_log( - "Ignoring config change while suspended", log: PlayolaStationPlayer.logger, type: .info) - return - } + /// 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. 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. 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 + 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 - if wasPlayingBeforeInterruption { - resumeAfterInterruption() + // 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 + 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 + // handled by play()'s generation gate.) + guard generation == playGeneration else { return } + try await play(stationId: stationToResume) + } - private func resumeAfterInterruption() { - guard let stationToResume = interruptedStationId else { return } - - os_log("Resuming playback after interruption", log: PlayolaStationPlayer.logger, type: .info) - - Task { @MainActor in - do { - try await PlayolaMainMixer.shared.audioSessionManager.activate() - try await PlayolaMainMixer.shared.restartEngine() - try await self.play(stationId: stationToResume) - } catch { - os_log( - "Failed to resume after interruption: %@", - log: PlayolaStationPlayer.logger, type: .error, - error.localizedDescription) + /// 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(). + /// + /// 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 + // 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 — 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 resume playback after interruption", level: .error) + error, context: "Failed to recover engine after configuration change", level: .error) } - - self.interruptedStationId = nil - self.wasPlayingBeforeInterruption = false + 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) } } - #endif + } deinit { fileDownloadManager.cancelAllDownloads() @@ -992,3 +1055,4 @@ 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 d6935d1..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() } @@ -612,16 +609,25 @@ 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. - // 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 @@ -769,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. @@ -777,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 { @@ -1043,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/InterruptionTransportTests.swift b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift new file mode 100644 index 0000000..4c3b0b6 --- /dev/null +++ b/Tests/PlayolaPlayerTests/InterruptionTransportTests.swift @@ -0,0 +1,162 @@ +// 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 AVFAudio +import Foundation +import Testing + +@testable import PlayolaPlayer + +@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( + 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("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( + 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/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) } } diff --git a/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift new file mode 100644 index 0000000..3d84c2f --- /dev/null +++ b/Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift @@ -0,0 +1,44 @@ +// SessionSeamInvariantTests.swift +// PlayolaPlayer +// +// 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("No SDK source touches the process-global AVAudioSession") + func sdkNeverTouchesSharedSession() throws { + let thisFile = URL(fileURLWithPath: #filePath) + let repoRoot = + 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) + 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" { + 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. + for needle in ["AVAudioSession.sharedInstance", ".setCategory(", ".setActive("] + where content.contains(needle) { + offenders.append("\(url.lastPathComponent): \(needle)") + } + } + #expect(offenders.isEmpty, "SDK must not touch AVAudioSession: \(offenders)") + } +}