Skip to content

Commit dda547d

Browse files
authored
Merge pull request #100 from playola-radio/briankeane/resume-retry-fix
fix(sdk): one-shot retryable resume; stop() clears interruption state
2 parents 2063979 + 6a077a1 commit dda547d

2 files changed

Lines changed: 76 additions & 10 deletions

File tree

Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//
55
// Created by Brian D Keane on 12/29/24.
66
//
7+
// swiftlint:disable file_length
78
import AVFAudio
89
import Combine
910
import Foundation
@@ -847,6 +848,12 @@ final public class PlayolaStationPlayer: ObservableObject {
847848
self.stationId = nil
848849
self.currentSchedule = nil
849850
self.state = .idle
851+
// stop() means "forget everything", including any armed interruption. Without
852+
// this, a later resumeAfterInterruption() (or a resume retry) could revive a
853+
// station the host explicitly stopped.
854+
self.isSuspended = false
855+
self.wasPlayingBeforeInterruption = false
856+
self.interruptedStationId = nil
850857

851858
os_log("✅ STOP completed", log: PlayolaStationPlayer.logger, type: .info)
852859
}
@@ -889,33 +896,60 @@ final public class PlayolaStationPlayer: ObservableObject {
889896

890897
/// Host-driven resume. Restarts the engine and replays the interrupted station
891898
/// re-synced to the current wall clock. The host owns the `AVAudioSession` and
892-
/// MUST have re-activated it before calling this. Throws so the host
893-
/// coordinator can render failures. No-op if nothing was interrupted OR if
894-
/// playback wasn't active when the pause happened.
899+
/// MUST have re-activated it before calling this. No-op if nothing was
900+
/// interrupted OR if playback wasn't active when the pause happened.
901+
///
902+
/// Consumes the interruption up-front: this is a one-shot attempt. On failure
903+
/// it both throws AND publishes `.error` state (so a host driving UI from
904+
/// `$state` sees the failure without catching the throw). **To retry, call
905+
/// `play(stationId:)`** — the station is available as the player's `stationId`
906+
/// (still set after a failed resume; only `stop()` clears it). The resume is
907+
/// abandoned cleanly if the host starts or stops playback while it's in flight.
895908
public func resumeAfterInterruption() async throws {
896909
guard let stationToResume = interruptedStationId,
897910
wasPlayingBeforeInterruption
898911
else { return }
912+
// Consume the armed interruption now — this attempt owns it. (play() would
913+
// clear these at its first lines anyway; clearing here keeps the failure
914+
// path honest: retry is via play(), not a second resume call.)
899915
isSuspended = false
916+
interruptedStationId = nil
917+
wasPlayingBeforeInterruption = false
918+
900919
// restartEngine() before play(): after an interruption the engine may be in
901920
// a stopped-but-stale CoreAudio state; play()'s lazy engine start does not
902921
// re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is
903922
// idempotent when healthy.
904-
try await mainMixer.restartEngine()
923+
let generation = playGeneration
924+
do {
925+
try await mainMixer.restartEngine()
926+
} catch {
927+
// Mirror play()'s error path so a host observing $state isn't left on a
928+
// stale .paused with no working resume — but only if a host stop()/play()
929+
// hasn't superseded us during the restart.
930+
if generation == playGeneration {
931+
state = .error(
932+
.playbackError("Failed to restart audio engine on resume: \(error.localizedDescription)"))
933+
}
934+
throw error
935+
}
936+
// A host stop()/play() during restartEngine bumped the generation — abandon
937+
// the resume so we don't revive a station the host stopped or override a
938+
// station it just started. (Supersession during play()'s own awaits is
939+
// handled by play()'s generation gate.)
940+
guard generation == playGeneration else { return }
905941
try await play(stationId: stationToResume)
906-
// Disarm only after a successful resume. If restartEngine()/play() throws
907-
// (e.g. the host hasn't reactivated the session yet), the armed state is
908-
// preserved so a later resumeAfterInterruption() retry still works.
909-
interruptedStationId = nil
910-
wasPlayingBeforeInterruption = false
911942
}
912943

913944
/// Recovers the SDK's own engine graph when AVAudioEngine reconfigures itself
914945
/// (hardware/format/route change) and stops. This is engine ownership, not
915946
/// session ownership: it touches no AVAudioSession API. Skipped while the host
916947
/// has paused for an interruption (the host drives resume then). Only acts
917948
/// while actively playing; re-syncs to the live wall clock via play().
918-
@objc public func handleAudioEngineConfigurationChange(_ notification: Notification) {
949+
///
950+
/// Internal (not public): it's a notification selector target, not host API —
951+
/// the host never calls it. `@objc` is required for `#selector`.
952+
@objc func handleAudioEngineConfigurationChange(_ notification: Notification) {
919953
os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info)
920954
guard !isSuspended, isPlaying, let stationToRecover = stationId else { return }
921955
Task { @MainActor in
@@ -988,3 +1022,4 @@ public protocol PlayolaStationPlayerDelegate: AnyObject {
9881022
_ player: PlayolaStationPlayer,
9891023
playerStateDidChange state: PlayolaStationPlayer.State)
9901024
}
1025+
// swiftlint:enable file_length

Tests/PlayolaPlayerTests/InterruptionTransportTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,37 @@ struct InterruptionTransportTests {
112112
#expect(player.wasPlayingBeforeInterruptionForTesting == true)
113113
}
114114

115+
@Test("stop() clears armed interruption state so a later resume can't revive it")
116+
func stopClearsArmedInterruptionState() {
117+
let player = PlayolaStationPlayer(
118+
fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession())
119+
player.configure(authProvider: MockAuthProvider())
120+
player.setStateForTesting(.playing(.mock), stationId: "station-1")
121+
player.pauseForInterruption()
122+
#expect(player.interruptedStationIdForTesting == "station-1") // armed
123+
124+
player.stop()
125+
126+
#expect(player.interruptedStationIdForTesting == nil)
127+
#expect(player.wasPlayingBeforeInterruptionForTesting == false)
128+
#expect(player.isSuspendedForTesting == false)
129+
}
130+
131+
@Test("resumeAfterInterruption after a stop is a no-op")
132+
func resumeAfterStopIsNoOp() async throws {
133+
let player = PlayolaStationPlayer(
134+
fileDownloadManager: MockFileDownloadManager(), urlSession: MockURLSession())
135+
player.configure(authProvider: MockAuthProvider())
136+
player.setStateForTesting(.playing(.mock), stationId: "station-1")
137+
player.pauseForInterruption()
138+
player.stop()
139+
140+
// Guard fails (stop cleared the armed fields) → returns before touching the
141+
// engine, so this never resolves the shared mixer / starts CoreAudio.
142+
try await player.resumeAfterInterruption()
143+
#expect(player.isPlaying == false)
144+
}
145+
115146
@Test("Pause during loading arms resume and clears the spinner state")
116147
func pauseDuringLoadingArmsResumeAndClearsSpinner() {
117148
let player = PlayolaStationPlayer(

0 commit comments

Comments
 (0)