Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
44f7e19
feat(sdk): NoOpAudioSessionManager + session-seam invariant test
briankeane Jun 9, 2026
e58095d
test(sdk): polish seam invariant test (review feedback)
briankeane Jun 9, 2026
68dd6ae
feat(sdk): protocol-typed session manager; guarded applyOwnership
briankeane Jun 9, 2026
434eef7
docs(sdk): document sessionTouched tripwire field
briankeane Jun 9, 2026
05d4710
fix(sdk): main-actor isolate session-touching mixer methods (review)
briankeane Jun 9, 2026
b2c356f
feat(sdk): audioSessionOwnership in configure(); host mode ignores se…
briankeane Jun 9, 2026
667154b
fix(sdk): mirror mixer-applied ownership in configure (review)
briankeane Jun 9, 2026
f389624
feat(sdk): generation-safe pauseForInterruption + throwing resumeAfte…
briankeane Jun 9, 2026
5ee19f0
fix(sdk): pause hazards — loading state, double-pause, example app .p…
briankeane Jun 9, 2026
decf502
docs(sdk): document host audio-session ownership contract
briankeane Jun 9, 2026
6819668
fix(sdk): lazy shared-mixer resolution, safe ownership fallback, stal…
briankeane Jun 9, 2026
fdc3970
fix: CI hang (no CoreAudio on headless runners), lint, review feedback
briankeane Jun 9, 2026
b717c06
refactor(sdk)!: host-only audio-session ownership (remove dual mode)
briankeane Jun 10, 2026
c78451f
fix(sdk): engine-config self-recovery + preserve resume token on fail…
briankeane Jun 10, 2026
0a780c8
fix(example): retain interruption observer token (review)
briankeane Jun 10, 2026
8d3baa8
docs(sdk): 0.20.0 changelog/migration; scope engine-config observer t…
briankeane Jun 10, 2026
2063979
Merge pull request #98 from playola-radio/briankeane/avplayer-higher-…
briankeane Jun 10, 2026
4db98b5
fix(sdk): one-shot retryable resume; stop() clears interruption state
briankeane Jun 10, 2026
6a077a1
fix(sdk): publish .error on resume engine-restart failure; clarify re…
briankeane Jun 10, 2026
dda547d
Merge pull request #100 from playola-radio/briankeane/resume-retry-fix
briankeane Jun 10, 2026
b43a63d
fix(sdk): generation-gate + .error state in engine-config recovery
briankeane Jun 10, 2026
82da3ba
fix(sdk): deliver engine-config recovery on main; gate failure report…
briankeane Jun 10, 2026
fd00e3b
Merge pull request #101 from playola-radio/briankeane/engine-recovery…
briankeane Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 22 additions & 4 deletions PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -508,6 +519,8 @@ func buttonColor(for state: PlayolaStationPlayer.State) -> Color {
return Color.green
case .error:
return Color.green
case .paused:
return Color.green
}
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading
Loading