Skip to content

Commit cb191f9

Browse files
authored
Merge pull request #99 from playola-radio/develop
0.20.0 - Host-only audio-session ownership
2 parents c8954e6 + fd00e3b commit cb191f9

11 files changed

Lines changed: 692 additions & 345 deletions

CHANGELOG.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,63 @@
22

33
All notable changes to PlayolaPlayer are documented here. This project follows
44
[Semantic Versioning](https://semver.org/). Versions correspond to git tags,
5-
which Swift Package Manager consumers pin to.
5+
which Swift Package Manager consumers pin to. Pre-1.0, breaking changes bump the
6+
minor version.
7+
8+
## 0.20.0
9+
10+
This release makes the **host app the sole owner of the `AVAudioSession`.** The
11+
SDK previously configured/activated the session and self-handled interruptions
12+
and route changes; it no longer touches `AVAudioSession` at all. This lets the
13+
SDK coexist cleanly with other audio subsystems in your app (URL streaming,
14+
recording, VoIP) instead of fighting them for the process-global session.
15+
16+
### Removed
17+
18+
- **The SDK no longer manages the `AVAudioSession`.** `AudioSessionManager` is
19+
gone, `PlayolaMainMixer` no longer exposes `configureAudioSession()` /
20+
`ensureAudioSessionConfigured()` / `deactivateAudioSession()`, and
21+
`PlayolaStationPlayer` no longer registers `AVAudioSession.interruptionNotification`
22+
/ `routeChangeNotification` observers. The `handleAudioSessionInterruption(_:)`
23+
and `handleAudioRouteChange(_:)` methods are removed.
24+
25+
**Source-breaking — required host changes:**
26+
27+
1. **Own the session.** Configure and activate it before `play(stationId:)`:
28+
`try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])`
29+
then `setActive(true)`. The SDK no longer does this; without it,
30+
`play(stationId:)` fails when the engine starts (surfaced as `.error`).
31+
32+
2. **Drive interruptions yourself.** Observe `AVAudioSession.interruptionNotification`
33+
(and route changes as needed) and call the new `pauseForInterruption()` /
34+
`resumeAfterInterruption()` (below). Reactivate the session before calling
35+
resume.
36+
37+
See the [Audio session](README.md#audio-session) and migration sections of the
38+
README for copy-paste host setup and a full interruption-handling example.
39+
40+
### Added
41+
42+
- **Host-driven interruption transport.**
43+
- `PlayolaStationPlayer.pauseForInterruption()` — silences playback,
44+
cancels scheduling/downloads, and is generation-fenced so in-flight work
45+
from before the pause can't publish state afterward. Preserves only the
46+
station identity (wall-clock radio has no frozen position). Idempotent
47+
across a double-pause.
48+
- `PlayolaStationPlayer.resumeAfterInterruption() async throws` — restarts the
49+
engine and replays the interrupted station re-synced to the live wall clock.
50+
Disarms only on success, so a failed resume (e.g. the host hasn't
51+
reactivated the session yet) stays retryable.
52+
53+
- **`PlayolaStationPlayer.State` gained a `.paused(Spin)` case.** Published while
54+
paused for an interruption; the `Spin` carries display metadata (title,
55+
artist, artwork URL) for the track that was playing. **Source-breaking for
56+
consumers** that `switch` exhaustively over `State`: add a `case .paused` arm.
57+
58+
- **Engine self-recovery.** The SDK now restarts and re-syncs its own engine
59+
graph on `AVAudioEngineConfigurationChange` (scoped to the SDK's own engine,
60+
so a host running a separate `AVAudioEngine` is unaffected). This is engine
61+
ownership, not session ownership — it touches no `AVAudioSession` API.
662

763
## 0.19.0
864

PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ struct ContentView: View {
129129
.font(.caption)
130130
.foregroundColor(.white.opacity(0.6))
131131
}
132+
} else if case .paused(let spin) = player.state {
133+
Text(spin.audioBlock.title)
134+
.font(.title2)
135+
.fontWeight(.semibold)
136+
.foregroundColor(.white.opacity(0.7))
137+
.lineLimit(1)
138+
139+
Text("Paused")
140+
.font(.headline)
141+
.foregroundColor(.white.opacity(0.5))
132142
} else if case .error(let error) = player.state {
133143
VStack(spacing: 8) {
134144
Image(systemName: "exclamationmark.triangle.fill")
@@ -249,8 +259,9 @@ struct ContentView: View {
249259
case .playing:
250260
// Stop playing
251261
await player.stop()
252-
case .idle, .error:
253-
// Start (or retry after a failed start)
262+
case .idle, .error, .paused:
263+
// Start (or retry after a failed start / resume after a pause —
264+
// play() re-fetches the schedule and re-syncs to now)
254265
do {
255266
try await player.play(stationId: selectedStationId)
256267
} catch {
@@ -508,6 +519,8 @@ func buttonColor(for state: PlayolaStationPlayer.State) -> Color {
508519
return Color.green
509520
case .error:
510521
return Color.green
522+
case .paused:
523+
return Color.green
511524
}
512525
}
513526

@@ -521,14 +534,19 @@ func buttonIcon(for state: PlayolaStationPlayer.State) -> String {
521534
return "play.fill"
522535
case .error:
523536
return "arrow.clockwise"
537+
case .paused:
538+
return "play.fill"
524539
}
525540
}
526541

527542
func shouldOffsetIcon(for state: PlayolaStationPlayer.State) -> Bool {
528-
if case .idle = state {
543+
// Offset centers the play triangle; .paused shows play.fill like .idle.
544+
switch state {
545+
case .idle, .paused:
529546
return true
547+
default:
548+
return false
530549
}
531-
return false
532550
}
533551

534552
// Custom button style for offset buttons

PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,77 @@
55
// Created by Brian D Keane on 12/29/24.
66
//
77

8+
import AVFoundation
9+
import PlayolaPlayer
810
import SwiftUI
911

1012
@main
1113
struct PlayolaPlayerExampleApp: App {
14+
private let audioSession = HostAudioSession()
15+
1216
var body: some Scene {
1317
WindowGroup {
1418
ContentView()
1519
}
1620
}
1721
}
22+
23+
/// PlayolaPlayer does not manage the `AVAudioSession` — the host app owns it.
24+
/// This demonstrates the full host contract: configure + activate the session,
25+
/// and drive `pauseForInterruption()` / `resumeAfterInterruption()` from the
26+
/// interruption notifications. A production app would also handle route changes.
27+
@MainActor
28+
final class HostAudioSession {
29+
/// Retained so the block observer can be removed in deinit and its lifetime
30+
/// is explicit (NotificationCenter holds the token until removal).
31+
private var interruptionObserver: (any NSObjectProtocol)?
32+
33+
init() {
34+
do {
35+
let session = AVAudioSession.sharedInstance()
36+
try session.setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])
37+
try session.setActive(true)
38+
} catch {
39+
print("Failed to configure AVAudioSession: \(error)")
40+
}
41+
42+
interruptionObserver = NotificationCenter.default.addObserver(
43+
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
44+
) { notification in
45+
MainActor.assumeIsolated {
46+
Self.handleInterruption(notification)
47+
}
48+
}
49+
}
50+
51+
deinit {
52+
if let interruptionObserver {
53+
NotificationCenter.default.removeObserver(interruptionObserver)
54+
}
55+
}
56+
57+
private static func handleInterruption(_ notification: Notification) {
58+
guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
59+
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
60+
else { return }
61+
62+
switch type {
63+
case .began:
64+
PlayolaStationPlayer.shared.pauseForInterruption()
65+
case .ended:
66+
guard let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt,
67+
AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume)
68+
else { return }
69+
Task {
70+
do {
71+
try AVAudioSession.sharedInstance().setActive(true)
72+
try await PlayolaStationPlayer.shared.resumeAfterInterruption()
73+
} catch {
74+
print("Resume failed: \(error)")
75+
}
76+
}
77+
@unknown default:
78+
break
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)