|
4 | 4 | // |
5 | 5 | // Created by Brian D Keane on 12/29/24. |
6 | 6 | // |
| 7 | +// swiftlint:disable file_length |
7 | 8 | import AVFAudio |
8 | 9 | import Combine |
9 | 10 | import Foundation |
@@ -847,6 +848,12 @@ final public class PlayolaStationPlayer: ObservableObject { |
847 | 848 | self.stationId = nil |
848 | 849 | self.currentSchedule = nil |
849 | 850 | 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 |
850 | 857 |
|
851 | 858 | os_log("✅ STOP completed", log: PlayolaStationPlayer.logger, type: .info) |
852 | 859 | } |
@@ -889,33 +896,60 @@ final public class PlayolaStationPlayer: ObservableObject { |
889 | 896 |
|
890 | 897 | /// Host-driven resume. Restarts the engine and replays the interrupted station |
891 | 898 | /// 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. |
895 | 908 | public func resumeAfterInterruption() async throws { |
896 | 909 | guard let stationToResume = interruptedStationId, |
897 | 910 | wasPlayingBeforeInterruption |
898 | 911 | 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.) |
899 | 915 | isSuspended = false |
| 916 | + interruptedStationId = nil |
| 917 | + wasPlayingBeforeInterruption = false |
| 918 | + |
900 | 919 | // restartEngine() before play(): after an interruption the engine may be in |
901 | 920 | // a stopped-but-stale CoreAudio state; play()'s lazy engine start does not |
902 | 921 | // re-prepare a torn-down graph. restartEngine() (stop+prepare+start) is |
903 | 922 | // 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 } |
905 | 941 | 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 |
911 | 942 | } |
912 | 943 |
|
913 | 944 | /// Recovers the SDK's own engine graph when AVAudioEngine reconfigures itself |
914 | 945 | /// (hardware/format/route change) and stops. This is engine ownership, not |
915 | 946 | /// session ownership: it touches no AVAudioSession API. Skipped while the host |
916 | 947 | /// has paused for an interruption (the host drives resume then). Only acts |
917 | 948 | /// 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) { |
919 | 953 | os_log("Audio engine configuration changed", log: PlayolaStationPlayer.logger, type: .info) |
920 | 954 | guard !isSuspended, isPlaying, let stationToRecover = stationId else { return } |
921 | 955 | Task { @MainActor in |
@@ -988,3 +1022,4 @@ public protocol PlayolaStationPlayerDelegate: AnyObject { |
988 | 1022 | _ player: PlayolaStationPlayer, |
989 | 1023 | playerStateDidChange state: PlayolaStationPlayer.State) |
990 | 1024 | } |
| 1025 | +// swiftlint:enable file_length |
0 commit comments