react-native-audio-browser 0.2.0-next.28 → 0.2.0-next.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -1,761 +0,0 @@
1
- import Foundation
2
- import os.log
3
-
4
- #if canImport(NitroModules)
5
- import NitroModules
6
- #endif
7
-
8
- /// Owns the playback state machine, side effect dispatch, observer context guards,
9
- /// and track management orchestration. Testable without AVFoundation by injecting
10
- /// a `PlaybackEffectHandler` (TrackPlayer in production, mock in tests).
11
- @MainActor
12
- class PlaybackCoordinator {
13
- let logger = Logger(subsystem: "com.audiobrowser", category: "PlaybackCoordinator")
14
-
15
- // MARK: - Dependencies
16
-
17
- weak var effectHandler: PlaybackEffectHandler?
18
- weak var callbacks: PlaybackCoordinatorCallbacks?
19
-
20
- let queue = QueueManager()
21
- let errorHandler: PlaybackErrorHandler
22
- let sleepTimerManager: any SleepTimerHandling
23
- let loadSeekCoordinator = LoadSeekCoordinator()
24
-
25
- lazy var volumeFader = VolumeFader(
26
- getVolume: { [weak self] in self?.effectHandler?.volume ?? 1 },
27
- setVolume: { [weak self] level in self?.effectHandler?.volume = level },
28
- )
29
-
30
- lazy var playingStateManager: PlayingStateManager = PlayingStateManager { [weak self] state in
31
- self?.callbacks?.playerDidChangePlayingState(state)
32
- }
33
-
34
- private lazy var progressTimer = PlaybackTimer(
35
- isActive: { $0 == .loading || $0 == .buffering || $0 == .playing },
36
- ) { [weak self] in
37
- guard let self, currentIndex >= 0, let effectHandler else { return }
38
- let progressEvent = PlaybackProgressUpdatedEvent(
39
- track: Double(currentIndex),
40
- position: effectHandler.currentTime,
41
- duration: effectHandler.duration,
42
- buffered: effectHandler.bufferedPosition,
43
- )
44
- callbacks?.playerDidUpdateProgress(progressEvent)
45
- }
46
-
47
- private lazy var intervalTimer = PlaybackTimer(
48
- isActive: { $0 == .playing },
49
- ) { [weak self] in
50
- self?.callbacks?.playerDidFirePlaybackInterval()
51
- }
52
-
53
- // MARK: - State
54
-
55
- private(set) var state: PlaybackState = .none
56
- var playbackError: TrackPlayerError.PlaybackError?
57
- /// True while `playbackError` is advisory — the retry loop is still working
58
- /// on it and the state stays non-terminal. Terminal errors (state == .error)
59
- /// always carry false.
60
- private(set) var isRetryingError = false
61
-
62
- /// The JS-facing form of `playbackError`, carrying the retrying flag.
63
- func nitroPlaybackError() -> PlaybackError? {
64
- playbackError?.toNitroError(retrying: isRetryingError)
65
- }
66
-
67
- /// Whether playback should resume when the current audio-session interruption
68
- /// ends — set to the play intent captured at interruption start, so we only
69
- /// auto-resume if we were actually playing when interrupted.
70
- private var shouldResumeAfterInterruption = false
71
-
72
- /// Seconds of continuous audible playback after which the retry budget is
73
- /// refilled — proof the stream actually delivered audio, not a brief blip
74
- /// (mirrors Android's HEALTHY_PLAYBACK_MS). Without the refill a long-lived
75
- /// stream permanently loses retry after its first recovered error, because
76
- /// the window/attempt counters otherwise reset only on track change.
77
- /// Overridable in tests.
78
- var healthyPlaybackDuration: TimeInterval = 20
79
-
80
- /// The pending refill. Readable so tests can await the window instead of
81
- /// sleeping past it; only this type arms and cancels it.
82
- private(set) var healthyPlaybackTask: Task<Void, Never>?
83
-
84
- /// Playback rate (1.0 = normal speed).
85
- var rate: Float = 1.0
86
-
87
- /// Audio time pitch algorithm identifier (stored as a String to avoid AVFoundation import).
88
- var audioTimePitchAlgorithm: String = "TimeDomain"
89
-
90
- // MARK: - Track State (for active track changed events)
91
-
92
- var lastIndex: Int = -1
93
- var lastTrack: Track?
94
-
95
- // MARK: - Queue Forwarding Properties
96
-
97
- var tracks: [Track] { queue.tracks }
98
- var currentIndex: Int { queue.currentIndex }
99
- var currentTrack: Track? { queue.currentTrack }
100
- var queueSourcePath: String? { queue.queueSourcePath }
101
- var nextTracks: [Track] { queue.nextTracks }
102
- var previousTracks: [Track] { queue.previousTracks }
103
- var isLastInPlaybackOrder: Bool { queue.isLastInPlaybackOrder }
104
- var canNext: Bool { queue.canNext }
105
- var canPrevious: Bool { queue.canPrevious }
106
-
107
- /// The repeat mode for the queue player.
108
- var repeatMode: RepeatMode {
109
- get { queue.repeatMode }
110
- set {
111
- guard queue.repeatMode != newValue else { return }
112
- queue.repeatMode = newValue
113
- effectHandler?.updateRemoteRepeatMode(newValue)
114
- pushSkipAvailability() // repeat-all wrap changes next/previous availability
115
- callbacks?.playerDidChangeRepeatMode(
116
- RepeatModeChangedEvent(repeatMode: newValue),
117
- )
118
- }
119
- }
120
-
121
- /// Whether shuffle mode is enabled.
122
- var shuffleEnabled: Bool {
123
- get { queue.shuffleEnabled }
124
- set {
125
- guard queue.shuffleEnabled != newValue else { return }
126
- queue.shuffleEnabled = newValue
127
- effectHandler?.updateRemoteShuffleMode(newValue)
128
- pushSkipAvailability() // shuffle reorders playback → boundary changes
129
- callbacks?.playerDidChangeShuffleEnabled(newValue)
130
- }
131
- }
132
-
133
- /// Pushes current next/previous availability to the remote command center so
134
- /// CarPlay / lock-screen grey out the buttons when there's nowhere to skip.
135
- private func pushSkipAvailability() {
136
- effectHandler?.updateSkipAvailability(canNext: queue.canNext, canPrevious: queue.canPrevious)
137
- }
138
-
139
- // MARK: - playWhenReady
140
-
141
- var playWhenReady: Bool = false {
142
- didSet {
143
- // A play during an interruption takes ownership of the intent — the
144
- // user's subsequent pause must not be overridden by auto-resume.
145
- // (handleInterruptionEnded consumes its flag before its own play().)
146
- if playWhenReady { shouldResumeAfterInterruption = false }
147
-
148
- // Terminal states need a reload, not a bare play(): the item is gone
149
- // (.stopped/.error) or parked at its end (.ended), where startPlayback()
150
- // is a silent no-op. Only .error resumes from the last position.
151
- if playWhenReady == true, state == .error || state == .stopped || state == .ended {
152
- effectHandler?.reloadTrack(startFromCurrentTime: state == .error)
153
- }
154
- if state != .loading {
155
- if playWhenReady {
156
- effectHandler?.startPlayback()
157
- } else {
158
- effectHandler?.pausePlayback()
159
- }
160
- }
161
-
162
- if oldValue != playWhenReady {
163
- // An explicit pause during the sleep fade is the timer's goal arriving
164
- // early: clear the timer (restores the pre-fade volume via onFadeCancel).
165
- if !playWhenReady, volumeFader.isActive {
166
- sleepTimerManager.clear()
167
- }
168
- callbacks?.playerDidChangePlayWhenReady(playWhenReady)
169
- playingStateManager.update(playWhenReady: playWhenReady, state: state)
170
- // Reflect play/pause intent immediately — even while the new item is
171
- // still loading. Auto-publishing doesn't set the explicit now-playing
172
- // playback state, and the player's timeControlStatus can't report
173
- // "playing" yet during loading. Guarded to active states so a play()
174
- // from a terminal state doesn't leave a phantom "playing" button.
175
- if playbackActive {
176
- effectHandler?.updateNowPlayingState(playWhenReady: playWhenReady)
177
- }
178
- evaluateSessionRelease()
179
- }
180
- }
181
- }
182
-
183
- var playbackActive: Bool {
184
- switch state {
185
- case .none, .stopped, .ended, .error:
186
- false
187
- default: true
188
- }
189
- }
190
-
191
- // MARK: - Init
192
-
193
- init(errorHandler: PlaybackErrorHandler, sleepTimerManager: any SleepTimerHandling) {
194
- self.errorHandler = errorHandler
195
- self.sleepTimerManager = sleepTimerManager
196
-
197
- queue.delegate = self
198
-
199
- // Configure sleep timer
200
- sleepTimerManager.onComplete = { [weak self] in
201
- guard let self else { return }
202
- self.volumeFader.resolve { self.pause() }
203
- }
204
- sleepTimerManager.onFadeStart = { [weak self] duration in
205
- self?.volumeFader.start(duration: duration)
206
- }
207
- sleepTimerManager.onFadeCancel = { [weak self] in
208
- self?.volumeFader.cancel(restoringVolume: true)
209
- }
210
-
211
- // Wire error handler to state machine
212
- errorHandler.onError = { [weak self] error in
213
- self?.transition(.errorOccurred(error))
214
- }
215
- errorHandler.onRetryingError = { [weak self] error in
216
- self?.reportRetryingError(error)
217
- }
218
- }
219
-
220
- /// Surfaces a failure the retry loop is still working on: attached to the
221
- /// current (non-terminal) state so UIs can show the cause over the spinner.
222
- /// Not a state transition — the state machine never sees it — and not a
223
- /// `playerDidError`, which stays reserved for terminal errors.
224
- private func reportRetryingError(_ error: TrackPlayerError.PlaybackError) {
225
- // Only attach while the failure is what the listener is looking at (matches
226
- // Android). During .playing buffered audio is still audibly fine — the
227
- // advisory would replace the song line over working sound — and a
228
- // terminal/stopped state must not regain an advisory error.
229
- guard state == .loading || state == .buffering || state == .paused else { return }
230
- // Each failed attempt re-reports; identical repeats add nothing.
231
- if isRetryingError, playbackError == error { return }
232
- playbackError = error
233
- isRetryingError = true
234
- callbacks?.playerDidChangePlayback(
235
- Playback(state: state, error: nitroPlaybackError()),
236
- )
237
- }
238
-
239
- // MARK: - Playback State Machine
240
-
241
- func transition(_ event: PlaybackEvent) {
242
- guard let newState = nextPlaybackState(from: state, on: event) else { return }
243
-
244
- // Allow error-to-error transitions to update the error and emit callbacks,
245
- // even though the state enum value doesn't change.
246
- if newState == state, case let .errorOccurred(error) = event {
247
- playbackError = error
248
- isRetryingError = false
249
- callbacks?.playerDidChangePlayback(
250
- Playback(state: state, error: nitroPlaybackError()),
251
- )
252
- callbacks?.playerDidError(
253
- PlaybackErrorEvent(error: nitroPlaybackError()),
254
- )
255
- return
256
- }
257
-
258
- guard newState != state else { return }
259
- let oldState = state
260
- state = newState
261
- applySideEffects(old: oldState, new: newState, event: event)
262
- emitStateChange(old: oldState, new: newState)
263
- }
264
-
265
- private func applySideEffects(old: PlaybackState, new: PlaybackState, event: PlaybackEvent) {
266
- // Error lifecycle
267
- if old == .error, new != .error {
268
- playbackError = nil
269
- }
270
- // A retrying error rides along through loading/buffering, but any state
271
- // that proves recovery (.ready/.playing — data flowed) or abandons the
272
- // attempt (.stopped/.none/.ended) clears it. Runs before emitStateChange,
273
- // so the emitted Playback is already clean.
274
- if isRetryingError, new != .loading, new != .buffering, new != .paused {
275
- playbackError = nil
276
- isRetryingError = false
277
- }
278
- if case let .errorOccurred(error) = event {
279
- playbackError = error
280
- isRetryingError = false
281
- }
282
-
283
- // Retry-budget refill: sustained audible playback proves the stream
284
- // recovered, so the next error starts a fresh retry window. Leaving
285
- // .playing before the threshold cancels the pending refill.
286
- if new == .playing {
287
- healthyPlaybackTask?.cancel()
288
- healthyPlaybackTask = Task { [weak self] in
289
- guard let self else { return }
290
- try? await Task.sleep(nanoseconds: UInt64(healthyPlaybackDuration * 1_000_000_000))
291
- guard !Task.isCancelled else { return }
292
- // Refill only between episodes: resetRetry() cancels a pending retry,
293
- // which would silently kill an in-flight reconnect.
294
- guard errorHandler.pendingRetryTask == nil else { return }
295
- errorHandler.resetRetry()
296
- }
297
- } else if old == .playing {
298
- healthyPlaybackTask?.cancel()
299
- healthyPlaybackTask = nil
300
- }
301
-
302
- // State-specific effects
303
- switch new {
304
- case .ready:
305
- effectHandler?.setTimePitchingAlgorithmForCurrentItem()
306
- if playWhenReady { effectHandler?.startPlayback() }
307
- case .loading:
308
- effectHandler?.setTimePitchingAlgorithmForCurrentItem()
309
- case .ended:
310
- // A natural end exhausts the play intent — nothing is left to play.
311
- // Keeping playWhenReady true would invert the play/pause toggle, hold
312
- // the audio session forever (release is intent-gated), and arm
313
- // interruption auto-resume into silence. The setter also requests the
314
- // session release.
315
- playWhenReady = false
316
- default: break
317
- }
318
-
319
- // Reflect play/pause (auto-publishing doesn't set the explicit now-playing
320
- // playback state). Active states show the user's intent — incl. .loading, so
321
- // the button flips to "playing" the moment a play-intent load begins; terminal
322
- // states (error/stopped/ended) resolve to paused so the button never sticks on
323
- // "playing" after playback fails or stops.
324
- effectHandler?.updateNowPlayingState(playWhenReady: playbackActive && playWhenReady)
325
-
326
- progressTimer.onPlaybackStateChanged(new)
327
- intervalTimer.onPlaybackStateChanged(new)
328
- playingStateManager.update(playWhenReady: playWhenReady, state: new)
329
- }
330
-
331
- private func emitStateChange(old: PlaybackState, new: PlaybackState) {
332
- // Playback state change — always emitted
333
- callbacks?.playerDidChangePlayback(
334
- Playback(state: new, error: nitroPlaybackError()),
335
- )
336
-
337
- // Error callback — emitted when entering or leaving error state
338
- if new == .error || (old == .error && new != .error) {
339
- callbacks?.playerDidError(
340
- PlaybackErrorEvent(error: nitroPlaybackError()),
341
- )
342
- }
343
-
344
- // Queue ended — when playback ends on the last track. Repeat modes never
345
- // conceptually end the queue (matches web's endsQueue()): a repeat-track
346
- // pause at the track boundary (e.g. the end-of-track sleep timer) must not
347
- // read as "playlist over".
348
- if new == .ended, isLastInPlaybackOrder, repeatMode == .off, let effectHandler {
349
- callbacks?.playerDidEndQueue(
350
- PlaybackQueueEndedEvent(track: Double(currentIndex), position: effectHandler.currentTime),
351
- )
352
- }
353
- }
354
-
355
- // MARK: - Observer Context Guards
356
-
357
- func avPlayerDidChangeTimeControlStatus(_ status: PlayerTimeControlStatus) {
358
- // During loading, ignore stale timeControlStatus changes from old items.
359
- if state == .loading { return }
360
-
361
- switch status {
362
- case .paused:
363
- guard let effectHandler else { return }
364
- let currentState = state
365
- let currentTime = effectHandler.currentTime
366
- let duration = effectHandler.duration
367
- // Ignore pauses when near track end
368
- let nearTrackEnd = currentTime >= duration - 0.5 && duration > 0
369
-
370
- if nearTrackEnd {
371
- // Ignore - track completion will be handled by handleTrackDidPlayToEndTime
372
- } else if !effectHandler.hasLoadedAsset, currentState != .stopped {
373
- transition(.avPlayerPaused(hasAsset: false))
374
- } else if currentState != .error, currentState != .stopped {
375
- if !playWhenReady {
376
- transition(.avPlayerPaused(hasAsset: true))
377
- }
378
- }
379
- case .waitingToPlayAtSpecifiedRate:
380
- if effectHandler?.hasLoadedAsset == true {
381
- transition(.avPlayerWaiting)
382
- }
383
- case .playing:
384
- transition(.avPlayerPlaying)
385
- }
386
- }
387
-
388
- func avPlayerStatusDidFail(error: Error?, httpStatusCode: Int? = nil) {
389
- errorHandler.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
390
- }
391
-
392
- func avItemStatusDidChange(_ status: PlayerItemStatus, error: Error?, httpStatusCode: Int? = nil) {
393
- if status == .failed {
394
- errorHandler.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
395
- }
396
- }
397
-
398
- func audioDidStart() {
399
- // Don't override loading state
400
- if state == .loading { return }
401
- transition(.audioFrameDecoded)
402
- }
403
-
404
- func avItemDidUpdatePlaybackLikelyToKeepUp(_ playbackLikelyToKeepUp: Bool) {
405
- guard playbackLikelyToKeepUp else { return }
406
- guard effectHandler?.hasLoadedAsset == true else { return }
407
-
408
- if !loadSeekCoordinator.shouldDeferReadyTransition, state != .playing {
409
- logger.debug("avItemDidUpdatePlaybackLikelyToKeepUp → .ready")
410
- transition(.bufferingSufficient)
411
- }
412
- }
413
-
414
- // MARK: - Player Actions
415
-
416
- func play() {
417
- playWhenReady = true
418
- }
419
-
420
- func pause() {
421
- playWhenReady = false
422
- }
423
-
424
- func togglePlayback() {
425
- playWhenReady = !playWhenReady
426
- }
427
-
428
- func stop() {
429
- // Cancel before the intent drop: a surviving retry would surface .error
430
- // over the stop, and a pending task blocks the session release below.
431
- errorHandler.cancelPendingRetry()
432
- // A stop is terminal — interruption-end must not resume over it.
433
- shouldResumeAfterInterruption = false
434
- transition(.stopped)
435
- playWhenReady = false
436
- }
437
-
438
- // MARK: - Audio Session Interruptions
439
-
440
- /// The active output route disappeared (headphones unplugged, Bluetooth
441
- /// speaker off). A deliberate output loss: drop the intent and any pending
442
- /// interruption auto-resume, so a call ending can't blast the built-in
443
- /// speaker.
444
- func handleRouteDisconnected() {
445
- shouldResumeAfterInterruption = false
446
- pause()
447
- }
448
-
449
- /// An audio-session interruption began (a phone call, or another app such as
450
- /// Music/Spotify taking over playback). iOS has already paused our AVPlayer,
451
- /// but that pause arrives as a `timeControlStatus` change that's swallowed
452
- /// while `playWhenReady` is still true — so the state would otherwise stay
453
- /// `.playing` and any UI driven by it stays stuck. Drop the play intent and
454
- /// force the paused state, independent of notification delivery order.
455
- func handleInterruptionBegan() {
456
- // OR: a nested interruption's second .began sees the already-paused
457
- // intent and must not wipe the flag the first one captured.
458
- shouldResumeAfterInterruption = shouldResumeAfterInterruption || playWhenReady
459
- pause()
460
- if playbackActive, state != .loading {
461
- transition(.avPlayerPaused(hasAsset: effectHandler?.hasLoadedAsset ?? false))
462
- }
463
- }
464
-
465
- /// Whether an interruption-end will resume playback — the intent captured at
466
- /// interruption start. Lets the host gate side effects that must precede the
467
- /// resume (audio-session reactivation) on whether one will actually happen.
468
- var willResumeAfterInterruption: Bool { shouldResumeAfterInterruption }
469
-
470
- /// An audio-session interruption ended. Resume only if we were playing when it
471
- /// began and the system indicates resumption is appropriate (`shouldResume`).
472
- func handleInterruptionEnded(shouldResume: Bool) {
473
- guard shouldResumeAfterInterruption else { return }
474
- shouldResumeAfterInterruption = false
475
- if shouldResume {
476
- play()
477
- } else {
478
- // Interruption ended and we're staying paused: the session can be released now.
479
- evaluateSessionRelease()
480
- }
481
- }
482
-
483
- /// Requests an audio-session release when playback is deliberately stopped: play intent is off,
484
- /// it isn't an interruption-driven pause (those resume — `shouldResumeAfterInterruption` is set
485
- /// before the interruption's `pause()`, so this skips it), and no retry is pending (a live-stream
486
- /// reconnect keeps `playWhenReady` true and must keep the session). The host debounces and cancels
487
- /// on the next activation, so a quick pause→resume never actually releases.
488
- private func evaluateSessionRelease() {
489
- guard !playWhenReady,
490
- !shouldResumeAfterInterruption,
491
- errorHandler.pendingRetryTask == nil
492
- else { return }
493
- callbacks?.playerShouldReleaseSession()
494
- }
495
-
496
- func getPlayback() -> Playback {
497
- Playback(state: state, error: nitroPlaybackError())
498
- }
499
-
500
- func getRepeatMode() -> RepeatMode {
501
- repeatMode
502
- }
503
-
504
- func setRepeatMode(_ mode: RepeatMode) {
505
- repeatMode = mode
506
- }
507
-
508
- /// Handles the `playWhenReady` setting while executing a given action.
509
- func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
510
- if playWhenReady == false {
511
- self.playWhenReady = false
512
- }
513
-
514
- try action()
515
-
516
- if playWhenReady == true {
517
- self.playWhenReady = true
518
- }
519
- }
520
-
521
- // MARK: - Queue Methods
522
-
523
- func load(_ track: Track, playWhenReady: Bool? = nil) {
524
- handlePlayWhenReady(playWhenReady) {
525
- if queue.currentIndex == -1 {
526
- let changed = queue.add([track], initialIndex: 0)
527
- if changed { handleCurrentTrackChanged() }
528
- } else {
529
- // `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
530
- // above, so this cannot fail — but surface it rather than reloading the
531
- // old track if that invariant is ever broken.
532
- do {
533
- try queue.replace(queue.currentIndex, track)
534
- handleCurrentTrackChanged()
535
- } catch {
536
- logger.error("load: replace at \(self.queue.currentIndex) failed: \(error)")
537
- }
538
- }
539
- }
540
- }
541
-
542
- func replace(_ index: Int, _ track: Track) throws {
543
- try queue.replace(index, track)
544
- }
545
-
546
- func setQueue(
547
- _ newTracks: [Track],
548
- initialIndex: Int = 0,
549
- startPositionMs: Double? = nil,
550
- playWhenReady: Bool? = nil,
551
- sourcePath: String? = nil,
552
- ) {
553
- guard !newTracks.isEmpty else {
554
- clear()
555
- return
556
- }
557
- handlePlayWhenReady(playWhenReady) {
558
- queue.setQueue(newTracks, initialIndex: initialIndex, sourcePath: sourcePath)
559
- handleCurrentTrackChanged()
560
- // Start position is applied as part of the load: captured as a pending
561
- // seek that runs once the item is ready, deferring the ready/play
562
- // transition until it lands (no start-at-0 flash).
563
- if let ms = startPositionMs, ms > 0 {
564
- loadSeekCoordinator.capture(position: ms / 1000)
565
- }
566
- }
567
- }
568
-
569
- func add(_ tracks: [Track], initialIndex: Int? = nil, playWhenReady: Bool? = nil) {
570
- handlePlayWhenReady(playWhenReady) {
571
- let changed = queue.add(tracks, initialIndex: initialIndex ?? 0)
572
- if changed { handleCurrentTrackChanged() }
573
- }
574
- }
575
-
576
- func add(_ tracks: [Track], at index: Int) throws {
577
- let changed = try queue.addAt(tracks, at: index)
578
- if changed { handleCurrentTrackChanged() }
579
- pushSkipAvailability() // didSet pushed pre-settlement state
580
- }
581
-
582
- func next() {
583
- let result = queue.next()
584
- switch result {
585
- case .trackChanged: handleCurrentTrackChanged()
586
- case .sameTrackReplay: if playWhenReady { replay() }
587
- case .noChange: break
588
- }
589
- }
590
-
591
- func previous() {
592
- let result = queue.previous()
593
- switch result {
594
- case .trackChanged: handleCurrentTrackChanged()
595
- case .sameTrackReplay: if playWhenReady { replay() }
596
- case .noChange: break
597
- }
598
- }
599
-
600
- func remove(_ index: Int) throws {
601
- let changed = try queue.remove(index)
602
- if changed { handleCurrentTrackChanged() }
603
- pushSkipAvailability() // didSet pushed pre-settlement state
604
- }
605
-
606
- func skipTo(_ index: Int, playWhenReady: Bool? = nil) throws {
607
- try handlePlayWhenReady(playWhenReady) {
608
- if index == queue.currentIndex {
609
- effectHandler?.seekToStart()
610
- } else {
611
- try queue.skipTo(index)
612
- handleCurrentTrackChanged()
613
- }
614
- }
615
- }
616
-
617
- func move(fromIndex: Int, toIndex: Int) throws {
618
- let changed = try queue.move(fromIndex: fromIndex, toIndex: toIndex)
619
- if changed { handleCurrentTrackChanged() }
620
- // tracks.didSet pushed availability mid-mutation, before the pointer and
621
- // shuffle order settled — re-push the real post-mutation state.
622
- pushSkipAvailability()
623
- }
624
-
625
- func removeUpcomingTracks() {
626
- queue.removeUpcomingTracks()
627
- pushSkipAvailability() // didSet pushed pre-settlement state
628
- }
629
-
630
- func replay() {
631
- effectHandler?.replayCurrentTrack()
632
- }
633
-
634
- func clear() {
635
- let changed = queue.clear()
636
- if changed { handleCurrentTrackChanged() }
637
- effectHandler?.unloadTrack()
638
- effectHandler?.clearNowPlaying()
639
- }
640
-
641
- // MARK: - Track Loading
642
-
643
- func handleTrackDidPlayToEndTime() {
644
- sleepTimerManager.onTrackPlayedToEnd()
645
-
646
- if repeatMode == .track {
647
- // The end-of-track sleep pause lands just above and clears the intent;
648
- // without it, settle in .ended (where play() reloads) instead of
649
- // replaying past the sleep timer.
650
- if playWhenReady {
651
- replay()
652
- } else {
653
- transition(.trackEndedNaturally)
654
- }
655
- } else if repeatMode == .queue || !isLastInPlaybackOrder {
656
- next()
657
- } else {
658
- transition(.trackEndedNaturally)
659
- }
660
- }
661
-
662
- func handleCurrentTrackChanged() {
663
- sleepTimerManager.onTrackChanged()
664
-
665
- effectHandler?.cancelMediaLoading()
666
- loadSeekCoordinator.reset()
667
-
668
- errorHandler.resetRetry()
669
-
670
- if playbackError != nil {
671
- playbackError = nil
672
- // A retrying error can be cleared while already .loading — the
673
- // .trackLoading transition below then won't fire, so emit the clear
674
- // here or the old track's error lingers over the new one's spinner.
675
- if isRetryingError, state == .loading {
676
- isRetryingError = false
677
- callbacks?.playerDidChangePlayback(Playback(state: state, error: nil))
678
- }
679
- isRetryingError = false
680
- }
681
-
682
- let lastPosition = effectHandler?.currentTime ?? 0
683
- let shouldContinuePlayback = playWhenReady
684
- if let currentTrack {
685
- effectHandler?.stopObservingCurrentItem()
686
- effectHandler?.pausePlayback()
687
-
688
- // Set loading state before playWhenReady so the setter's guard
689
- // prevents a no-op startPlayback() on the now-nil item.
690
- transition(.trackLoading)
691
-
692
- // Ensure playWhenReady is set before loading to preserve playback state
693
- playWhenReady = shouldContinuePlayback
694
-
695
- effectHandler?.loadNowPlayingMetadata(for: currentTrack)
696
-
697
- // Validate source URL before handing off to MediaLoader
698
- guard let src = currentTrack.src else {
699
- logger.error("Failed to load track - no src")
700
- logger.error(" track.title: \(currentTrack.title)")
701
- logger.error(" track.path: \(currentTrack.path ?? "nil")")
702
- effectHandler?.clearCurrentItem()
703
- transition(.errorOccurred(.invalidSourceUrl("nil")))
704
- return
705
- }
706
-
707
- logger.debug("Loading track: \(currentTrack.title)")
708
- logger.debug(" track.path: \(currentTrack.path ?? "nil")")
709
- logger.debug(" track.src: \(src)")
710
-
711
- effectHandler?.loadTrack(src: src, track: currentTrack)
712
- } else {
713
- effectHandler?.unloadTrack()
714
- effectHandler?.clearNowPlaying()
715
- }
716
-
717
- let eventData = PlaybackActiveTrackChangedEvent(
718
- lastIndex: lastIndex == -1 ? nil : Double(lastIndex),
719
- lastTrack: lastTrack,
720
- lastPosition: lastPosition,
721
- index: currentIndex == -1 ? nil : Double(currentIndex),
722
- track: currentTrack,
723
- )
724
- callbacks?.playerDidChangeActiveTrack(eventData)
725
- lastTrack = currentTrack
726
- lastIndex = currentIndex
727
- pushSkipAvailability() // position moved → next/previous availability may change
728
- }
729
-
730
- // MARK: - Seek Completion (called by TrackPlayer after AVPlayer seek lands)
731
-
732
- func handleSeekCompleted(to seconds: Double, didFinish: Bool) {
733
- if state == .loading {
734
- logger.debug("[loadSeek] seek landed at \(seconds)s (finished=\(didFinish)) → .ready")
735
- transition(.loadSeekCompleted)
736
- }
737
- }
738
-
739
- // MARK: - Progress Updates
740
-
741
- func setProgressUpdateInterval(_ interval: TimeInterval?) {
742
- progressTimer.setInterval(interval)
743
- }
744
-
745
- func setPlaybackIntervalEnabled(_ enabled: Bool) {
746
- intervalTimer.setInterval(enabled ? 1 : nil)
747
- }
748
- }
749
-
750
- // MARK: - QueueManagerDelegate
751
-
752
- extension PlaybackCoordinator: QueueManagerDelegate {
753
- func queueDidChangeTracks(_ tracks: [Track]) {
754
- callbacks?.playerDidChangeQueue(tracks)
755
- pushSkipAvailability() // queue contents changed → boundaries moved
756
- }
757
- }
758
-
759
- // MARK: - TrackSelectionPlayer
760
-
761
- extension PlaybackCoordinator: TrackSelectionPlayer {}