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
@@ -2,46 +2,43 @@
2
2
  import Foundation
3
3
  import Testing
4
4
 
5
- /// Helper to build a coordinator with mocks for testing.
5
+ /// Helper to build a player with mocks for testing.
6
6
  @MainActor
7
7
  private func makeCoordinator() -> (
8
- coordinator: PlaybackCoordinator,
9
- effectHandler: MockPlaybackEffectHandler,
10
- callbacks: MockPlaybackCoordinatorCallbacks,
11
- sleepTimer: MockSleepTimerHandling,
8
+ player: Player,
9
+ engine: MockPlayerEngine,
10
+ callbacks: MockPlayerCallbacks,
11
+ sleepTimer: SleepTimerManager,
12
12
  ) {
13
- let retryHandler = MockRetryHandling()
14
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
15
- let sleepTimer = MockSleepTimerHandling()
16
- let coordinator = PlaybackCoordinator(errorHandler: errorHandler, sleepTimerManager: sleepTimer)
17
- let effectHandler = MockPlaybackEffectHandler()
18
- let callbacks = MockPlaybackCoordinatorCallbacks()
19
- coordinator.effectHandler = effectHandler
20
- coordinator.callbacks = callbacks
21
- return (coordinator, effectHandler, callbacks, sleepTimer)
13
+ let sleepTimer = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
14
+ let engine = MockPlayerEngine()
15
+ let player = makePlayer(engine: engine, retries: false, sleepTimer: sleepTimer)
16
+ let callbacks = MockPlayerCallbacks()
17
+ player.callbacks = callbacks
18
+ return (player, engine, callbacks, sleepTimer)
22
19
  }
23
20
 
24
- /// Helper to load a track into the coordinator's queue so it has a currentTrack.
21
+ /// Helper to load a track into the player's queue so it has a currentTrack.
25
22
  @MainActor
26
23
  private func loadTrack(
27
- _ coordinator: PlaybackCoordinator,
24
+ _ player: Player,
28
25
  id: String = "t1",
29
26
  src: String? = "https://example.com/audio.mp3",
30
27
  title: String = "Test Track",
31
28
  ) {
32
29
  let track = Track(id: id, src: src, title: title)
33
- coordinator.setQueue([track])
30
+ player.setQueue([track])
34
31
  }
35
32
 
36
33
  // MARK: - Transition + Side Effects
37
34
 
38
- @Suite("PlaybackCoordinator - transition + side effects")
35
+ @Suite("Player - transition + side effects")
39
36
  struct TransitionTests {
40
37
  @Test @MainActor
41
38
  func toReady_startsPlayback_whenPlayWhenReady() {
42
39
  let (c, eh, _, _) = makeCoordinator()
43
40
  loadTrack(c)
44
- c.playWhenReady = true
41
+ c.setPlayWhenReady(true)
45
42
  eh.startPlaybackCallCount = 0 // reset from playWhenReady setter
46
43
 
47
44
  c.transition(.bufferingSufficient)
@@ -77,19 +74,19 @@ struct TransitionTests {
77
74
  c.transition(.errorOccurred(.playbackFailed))
78
75
 
79
76
  #expect(c.state == .error)
80
- #expect(c.playbackError == .playbackFailed)
77
+ #expect(c.errorStatus.error == .playbackFailed)
81
78
  }
82
79
 
83
80
  @Test @MainActor
84
81
  func leavingError_clearsPlaybackError() {
85
82
  let (c, _, _, _) = makeCoordinator()
86
83
  c.transition(.errorOccurred(.playbackFailed))
87
- #expect(c.playbackError == .playbackFailed)
84
+ #expect(c.errorStatus.error == .playbackFailed)
88
85
 
89
86
  c.transition(.trackLoading)
90
87
 
91
88
  #expect(c.state == .loading)
92
- #expect(c.playbackError == nil)
89
+ #expect(c.errorStatus.error == nil)
93
90
  }
94
91
 
95
92
  @Test @MainActor
@@ -103,7 +100,7 @@ struct TransitionTests {
103
100
 
104
101
  // State stays .error but error is updated
105
102
  #expect(c.state == .error)
106
- #expect(c.playbackError == .notConnectedToInternet)
103
+ #expect(c.errorStatus.error == .notConnectedToInternet)
107
104
  #expect(cb.playbackChanges.count == 1)
108
105
  #expect(cb.errorEvents.count == 1)
109
106
  }
@@ -114,29 +111,21 @@ struct TransitionTests {
114
111
  /// Advisory errors from the retry loop: attached to the current non-terminal
115
112
  /// state via `playerDidChangePlayback`, never a state transition and never a
116
113
  /// `playerDidError`.
117
- /// Builds a coordinator whose retry handler will accept the error and
118
- /// pretend the (instant) retry succeeded, so only the advisory path runs.
119
- /// The effect handler is returned solely to keep the weak reference alive.
114
+ /// Builds a player whose retries reload at once, so the advisory path is what shows.
120
115
  @MainActor
121
116
  private func makeRetrying() -> (
122
- PlaybackCoordinator, MockRetryHandling, MockPlaybackCoordinatorCallbacks,
123
- MockPlaybackEffectHandler,
117
+ Player, RetryLoop, MockPlayerCallbacks,
118
+ MockPlayerEngine,
124
119
  ) {
125
- let retryHandler = MockRetryHandling()
126
- retryHandler.isRetryableResult = true
127
- retryHandler.attemptRetryResult = true
128
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
129
- let coordinator = PlaybackCoordinator(
130
- errorHandler: errorHandler, sleepTimerManager: MockSleepTimerHandling(),
131
- )
132
- let effectHandler = MockPlaybackEffectHandler()
133
- coordinator.effectHandler = effectHandler
134
- let callbacks = MockPlaybackCoordinatorCallbacks()
135
- coordinator.callbacks = callbacks
136
- return (coordinator, retryHandler, callbacks, effectHandler)
120
+ let retryHandler = makeRetryLoop()
121
+ let engine = MockPlayerEngine()
122
+ let player = makePlayer(engine: engine, retry: retryHandler)
123
+ let callbacks = MockPlayerCallbacks()
124
+ player.callbacks = callbacks
125
+ return (player, retryHandler, callbacks, engine)
137
126
  }
138
127
 
139
- @Suite("PlaybackCoordinator - retrying errors")
128
+ @Suite("Player - retrying errors")
140
129
  struct RetryingErrorTests {
141
130
  @Test @MainActor
142
131
  func retryingError_attachesToCurrentState() async {
@@ -144,16 +133,16 @@ struct RetryingErrorTests {
144
133
  c.transition(.trackLoading)
145
134
  cb.playbackChanges.removeAll()
146
135
 
147
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
136
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
148
137
 
149
138
  #expect(c.state == .loading)
150
- #expect(c.playbackError == .hostUnreachable)
139
+ #expect(c.errorStatus.error == .hostUnreachable)
151
140
  #expect(cb.playbackChanges.count == 1)
152
141
  #expect(cb.playbackChanges.last?.state == .loading)
153
142
  #expect(cb.playbackChanges.last?.error?.retrying == true)
154
143
  // Advisory, not terminal: the error event is reserved for .error.
155
144
  #expect(cb.errorEvents.isEmpty)
156
- await c.errorHandler.pendingRetryTask?.value
145
+ await c.retry.pendingTask?.value
157
146
  }
158
147
 
159
148
  /// Every failed attempt re-reports through `handleError`; identical repeats
@@ -164,10 +153,10 @@ struct RetryingErrorTests {
164
153
  c.transition(.trackLoading)
165
154
  cb.playbackChanges.removeAll()
166
155
 
167
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
168
- await c.errorHandler.pendingRetryTask?.value
169
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
170
- await c.errorHandler.pendingRetryTask?.value
156
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
157
+ await c.retry.pendingTask?.value
158
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
159
+ await c.retry.pendingTask?.value
171
160
 
172
161
  #expect(cb.playbackChanges.count == 1)
173
162
  }
@@ -180,11 +169,11 @@ struct RetryingErrorTests {
180
169
  c.transition(.avPlayerPlaying)
181
170
  cb.playbackChanges.removeAll()
182
171
 
183
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .playback)
172
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .playback)
184
173
 
185
- #expect(c.playbackError == nil)
174
+ #expect(c.errorStatus.error == nil)
186
175
  #expect(cb.playbackChanges.isEmpty)
187
- await c.errorHandler.pendingRetryTask?.value
176
+ await c.retry.pendingTask?.value
188
177
  }
189
178
 
190
179
  /// Reaching .ready proves the retry recovered: the advisory error must not
@@ -193,13 +182,13 @@ struct RetryingErrorTests {
193
182
  func recovery_clearsTheRetryingError() async {
194
183
  let (c, _, cb, _) = makeRetrying()
195
184
  c.transition(.trackLoading)
196
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
197
- await c.errorHandler.pendingRetryTask?.value
185
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
186
+ await c.retry.pendingTask?.value
198
187
 
199
188
  c.transition(.loadSeekCompleted)
200
189
 
201
190
  #expect(c.state == .ready)
202
- #expect(c.playbackError == nil)
191
+ #expect(c.errorStatus.error == nil)
203
192
  #expect(cb.playbackChanges.last?.error == nil)
204
193
  }
205
194
 
@@ -209,12 +198,12 @@ struct RetryingErrorTests {
209
198
  func exhaustion_hardensIntoTerminalError() async {
210
199
  let (c, retryHandler, cb, _) = makeRetrying()
211
200
  c.transition(.trackLoading)
212
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
213
- await c.errorHandler.pendingRetryTask?.value
201
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
202
+ await c.retry.pendingTask?.value
214
203
 
215
- retryHandler.attemptRetryResult = false
216
- c.errorHandler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
217
- await c.errorHandler.pendingRetryTask?.value
204
+ retryHandler.shouldRetry = { false }
205
+ c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
206
+ await c.retry.pendingTask?.value
218
207
 
219
208
  #expect(c.state == .error)
220
209
  #expect(cb.playbackChanges.last?.state == .error)
@@ -225,7 +214,7 @@ struct RetryingErrorTests {
225
214
 
226
215
  // MARK: - emitStateChange
227
216
 
228
- @Suite("PlaybackCoordinator - emitStateChange")
217
+ @Suite("Player - emitStateChange")
229
218
  struct EmitStateChangeTests {
230
219
  @Test @MainActor
231
220
  func alwaysEmits_playerDidChangePlayback() {
@@ -275,7 +264,7 @@ struct EmitStateChangeTests {
275
264
 
276
265
  // MARK: - Observer Guards
277
266
 
278
- @Suite("PlaybackCoordinator - observer guards")
267
+ @Suite("Player - observer guards")
279
268
  struct ObserverGuardTests {
280
269
  @Test @MainActor
281
270
  func timeControlStatus_paused_nearTrackEnd_ignored() {
@@ -312,7 +301,7 @@ struct ObserverGuardTests {
312
301
  eh.hasLoadedAsset = true
313
302
  eh.currentTime = 10
314
303
  eh.duration = 100
315
- c.playWhenReady = false
304
+ c.setPlayWhenReady(false)
316
305
 
317
306
  c.avPlayerDidChangeTimeControlStatus(.paused)
318
307
 
@@ -326,7 +315,7 @@ struct ObserverGuardTests {
326
315
  eh.hasLoadedAsset = true
327
316
  eh.currentTime = 10
328
317
  eh.duration = 100
329
- c.playWhenReady = true
318
+ c.setPlayWhenReady(true)
330
319
 
331
320
  c.avPlayerDidChangeTimeControlStatus(.paused)
332
321
 
@@ -414,28 +403,37 @@ struct ObserverGuardTests {
414
403
 
415
404
  // MARK: - playWhenReady
416
405
 
417
- @Suite("PlaybackCoordinator - playWhenReady")
406
+ @Suite("Player - playWhenReady")
418
407
  struct PlayWhenReadyTests {
419
408
  @Test @MainActor
420
409
  func true_fromError_triggersReload() {
421
410
  let (c, eh, _, _) = makeCoordinator()
411
+ loadTrack(c)
412
+ eh.emit(.itemPrepared(likelyToKeepUp: false))
422
413
  c.transition(.errorOccurred(.playbackFailed))
414
+ eh.resumePosition = 42
415
+ let resolvesBefore = eh.resolveAndLoadCalls.count
423
416
 
424
- c.playWhenReady = true
417
+ c.setPlayWhenReady(true)
425
418
 
426
- #expect(eh.reloadTrackCalls.count == 1)
427
- #expect(eh.reloadTrackCalls.first == true) // startFromCurrentTime for error
419
+ // A restart from .error is a new Load on a new AVPlayer, resuming where it failed.
420
+ #expect(eh.recreateCount == 1)
421
+ #expect(eh.resolveAndLoadCalls.count == resolvesBefore + 1)
422
+ #expect(c.state == .loading)
423
+ #expect(c.deferredSeek.target == 42)
428
424
  }
429
425
 
430
426
  @Test @MainActor
431
427
  func true_fromStopped_triggersReload() {
432
428
  let (c, eh, _, _) = makeCoordinator()
433
429
  c.transition(.stopped)
430
+ eh.resumePosition = 42
434
431
 
435
- c.playWhenReady = true
432
+ c.setPlayWhenReady(true)
436
433
 
437
- #expect(eh.reloadTrackCalls.count == 1)
438
- #expect(eh.reloadTrackCalls.first == false) // startFromCurrentTime for stopped
434
+ #expect(eh.recreateCount == 0)
435
+ #expect(eh.loadResolvedCount == 1)
436
+ #expect(c.deferredSeek.target == nil) // a stop restarts from the start
439
437
  }
440
438
 
441
439
  @Test @MainActor
@@ -443,7 +441,7 @@ struct PlayWhenReadyTests {
443
441
  let (c, eh, _, _) = makeCoordinator()
444
442
  c.transition(.bufferingSufficient) // .ready state
445
443
 
446
- c.playWhenReady = true
444
+ c.setPlayWhenReady(true)
447
445
 
448
446
  #expect(eh.startPlaybackCallCount >= 1)
449
447
  }
@@ -452,10 +450,10 @@ struct PlayWhenReadyTests {
452
450
  func false_notLoading_pausesPlayback() {
453
451
  let (c, eh, _, _) = makeCoordinator()
454
452
  c.transition(.avPlayerPlaying)
455
- c.playWhenReady = true
453
+ c.setPlayWhenReady(true)
456
454
  eh.pausePlaybackCallCount = 0
457
455
 
458
- c.playWhenReady = false
456
+ c.setPlayWhenReady(false)
459
457
 
460
458
  #expect(eh.pausePlaybackCallCount == 1)
461
459
  }
@@ -467,7 +465,7 @@ struct PlayWhenReadyTests {
467
465
  eh.startPlaybackCallCount = 0
468
466
  eh.pausePlaybackCallCount = 0
469
467
 
470
- c.playWhenReady = true
468
+ c.setPlayWhenReady(true)
471
469
 
472
470
  // During loading, should NOT call start/pause
473
471
  #expect(eh.startPlaybackCallCount == 0)
@@ -478,7 +476,7 @@ struct PlayWhenReadyTests {
478
476
  func emitsCallback_onChange() {
479
477
  let (c, _, cb, _) = makeCoordinator()
480
478
 
481
- c.playWhenReady = true
479
+ c.setPlayWhenReady(true)
482
480
 
483
481
  #expect(cb.playWhenReadyChanges.count == 1)
484
482
  #expect(cb.playWhenReadyChanges.first == true)
@@ -487,7 +485,7 @@ struct PlayWhenReadyTests {
487
485
 
488
486
  // MARK: - Skip availability (next/previous greying)
489
487
 
490
- @Suite("PlaybackCoordinator - skip availability")
488
+ @Suite("Player - skip availability")
491
489
  struct SkipAvailabilityCoordinatorTests {
492
490
  private static func multiTrack() -> [Track] {
493
491
  [
@@ -541,25 +539,25 @@ struct SkipAvailabilityCoordinatorTests {
541
539
 
542
540
  // MARK: - handleCurrentTrackChanged
543
541
 
544
- @Suite("PlaybackCoordinator - handleCurrentTrackChanged")
542
+ @Suite("Player - handleCurrentTrackChanged")
545
543
  struct HandleCurrentTrackChangedTests {
546
544
  @Test @MainActor
547
545
  func clearsError_beforeTransition() {
548
546
  let (c, eh, _, _) = makeCoordinator()
549
547
  eh.hasLoadedAsset = true
550
548
  c.transition(.errorOccurred(.playbackFailed))
551
- #expect(c.playbackError != nil)
549
+ #expect(c.errorStatus.error != nil)
552
550
 
553
551
  loadTrack(c)
554
552
 
555
553
  // After loading a track, the error should be cleared
556
- #expect(c.playbackError == nil)
554
+ #expect(c.errorStatus.error == nil)
557
555
  }
558
556
 
559
557
  @Test @MainActor
560
558
  func preservesPlayWhenReady_acrossLoading() {
561
559
  let (c, _, _, _) = makeCoordinator()
562
- c.playWhenReady = true
560
+ c.setPlayWhenReady(true)
563
561
 
564
562
  loadTrack(c)
565
563
 
@@ -575,7 +573,7 @@ struct HandleCurrentTrackChangedTests {
575
573
  c.setQueue([track])
576
574
 
577
575
  #expect(c.state == .error)
578
- #expect(c.playbackError == .invalidSourceUrl("nil"))
576
+ #expect(c.errorStatus.error == .invalidSourceUrl("nil"))
579
577
  #expect(cb.errorEvents.count >= 1)
580
578
  }
581
579
 
@@ -595,19 +593,19 @@ struct HandleCurrentTrackChangedTests {
595
593
 
596
594
  // MARK: - handleTrackDidPlayToEndTime
597
595
 
598
- @Suite("PlaybackCoordinator - handleTrackDidPlayToEndTime")
596
+ @Suite("Player - handleTrackDidPlayToEndTime")
599
597
  struct HandleTrackDidPlayToEndTimeTests {
600
598
  @Test @MainActor
601
599
  func repeatTrack_replays() {
602
600
  let (c, eh, _, _) = makeCoordinator()
603
- loadTrack(c)
601
+ startPlaying(c, eh)
604
602
  c.repeatMode = .track
605
- c.playWhenReady = true
603
+ eh.startPlaybackCallCount = 0
606
604
 
607
605
  c.handleTrackDidPlayToEndTime()
608
606
 
609
- // replay delegates to effectHandler.replayCurrentTrack (seek to 0 + play)
610
- #expect(eh.replayCurrentTrackCallCount == 1)
607
+ #expect(eh.seekCalls == [0])
608
+ #expect(eh.startPlaybackCallCount == 1)
611
609
  }
612
610
 
613
611
  /// The end-of-track sleep timer pauses (clears intent) just before this
@@ -620,19 +618,18 @@ struct HandleTrackDidPlayToEndTimeTests {
620
618
  let (c, eh, cb, _) = makeCoordinator()
621
619
  loadTrack(c)
622
620
  c.repeatMode = .track
623
- c.playWhenReady = false
621
+ c.setPlayWhenReady(false)
624
622
 
625
623
  c.handleTrackDidPlayToEndTime()
626
624
 
627
625
  #expect(cb.queueEndedEvents.isEmpty)
628
- #expect(eh.replayCurrentTrackCallCount == 0)
626
+ #expect(eh.seekCalls.isEmpty)
629
627
  #expect(c.state == .ended)
630
628
  }
631
629
 
632
630
  @Test @MainActor
633
631
  func lastTrack_noRepeat_transitionsToEnded() {
634
632
  let (c, eh, cb, _) = makeCoordinator()
635
- _ = eh // retain weak effectHandler
636
633
  loadTrack(c)
637
634
  c.repeatMode = .off
638
635
 
@@ -666,7 +663,7 @@ struct HandleTrackDidPlayToEndTimeTests {
666
663
  /// `playWhenReady` must not stay true. Keeping it inverted the play/pause
667
664
  /// toggle, held the audio session forever, armed interruption auto-resume into
668
665
  /// silence, and made play-from-ended a silent no-op.
669
- @Suite("PlaybackCoordinator - natural end drops play intent")
666
+ @Suite("Player - natural end drops play intent")
670
667
  struct NaturalEndPlayIntentTests {
671
668
  @Test @MainActor
672
669
  func naturalEnd_clearsPlayWhenReady() {
@@ -697,11 +694,13 @@ struct NaturalEndPlayIntentTests {
697
694
  let (c, eh, _, _) = makeCoordinator()
698
695
  startPlaying(c, eh)
699
696
  c.handleTrackDidPlayToEndTime()
700
- eh.reloadTrackCalls.removeAll()
697
+ eh.resumePosition = 300
698
+ let resolvesBefore = eh.resolveAndLoadCalls.count
701
699
 
702
700
  c.play()
703
701
 
704
- #expect(eh.reloadTrackCalls == [false])
702
+ #expect(eh.resolveAndLoadCalls.count == resolvesBefore + 1)
703
+ #expect(c.deferredSeek.target == nil)
705
704
  }
706
705
 
707
706
  /// The cleared intent opens the call-site `!playWhenReady` gate, so a stray
@@ -724,13 +723,13 @@ struct NaturalEndPlayIntentTests {
724
723
  let (c, eh, _, _) = makeCoordinator()
725
724
  startPlaying(c, eh)
726
725
  c.handleTrackDidPlayToEndTime()
727
- eh.reloadTrackCalls.removeAll()
726
+ let resolvesBefore = eh.resolveAndLoadCalls.count
728
727
 
729
728
  c.handleInterruptionBegan()
730
729
  c.handleInterruptionEnded(shouldResume: true)
731
730
 
732
731
  #expect(c.playWhenReady == false) // nothing was playing — never resume
733
- #expect(eh.reloadTrackCalls.isEmpty)
732
+ #expect(eh.resolveAndLoadCalls.count == resolvesBefore)
734
733
  }
735
734
  }
736
735
 
@@ -740,153 +739,69 @@ struct NaturalEndPlayIntentTests {
740
739
  /// window/attempt counters must refill (mirrors Android's healthy-playback
741
740
  /// refill) — otherwise a long-lived stream permanently loses retry after its
742
741
  /// first recovered blip.
743
- @Suite("PlaybackCoordinator - healthy playback resets retry budget")
742
+ @Suite("Player - healthy playback resets retry budget")
744
743
  struct HealthyPlaybackRetryResetTests {
744
+ /// A player whose retry loop has already executed one retry, so a reset shows.
745
+ @MainActor
746
+ private func makeAfterOneRetry(delay: TimeInterval = 0) async -> (Player, RetryLoop, MockPlayerEngine) {
747
+ let retry = makeRetryLoop(delay: delay)
748
+ let engine = MockPlayerEngine()
749
+ let player = makePlayer(engine: engine, retry: retry)
750
+ if delay == 0 {
751
+ retry.handleError(URLError(.timedOut), context: .playback)
752
+ await retry.pendingTask?.value
753
+ }
754
+ return (player, retry, engine)
755
+ }
756
+
745
757
  @Test @MainActor
746
758
  func sustainedPlayback_resetsRetryBudget() async {
747
- let retryHandler = MockRetryHandling()
748
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
749
- let coordinator = PlaybackCoordinator(
750
- errorHandler: errorHandler, sleepTimerManager: MockSleepTimerHandling(),
751
- )
752
- let effectHandler = MockPlaybackEffectHandler()
753
- coordinator.effectHandler = effectHandler
754
- coordinator.healthyPlaybackDuration = 0.01
759
+ let (player, retry, _) = await makeAfterOneRetry()
760
+ #expect(retry.attempts == 1)
761
+ player.healthyPlaybackDuration = 0.01
755
762
 
756
- coordinator.transition(.avPlayerPlaying)
757
- await coordinator.healthyPlaybackTask?.value
763
+ player.transition(.avPlayerPlaying)
764
+ await player.healthyPlaybackTask?.value
758
765
 
759
- #expect(retryHandler.resetCallCount == 1)
766
+ #expect(retry.attempts == 0)
760
767
  }
761
768
 
762
- /// resetRetry() cancels a pending retry — firing the refill mid-episode
769
+ /// reset() cancels a pending retry — firing the refill mid-episode
763
770
  /// would kill an in-flight reconnect silently (stuck .playing forever).
764
771
  @Test @MainActor
765
772
  func refill_skipsWhileRetryIsPending() async {
766
- let retryHandler = MockRetryHandling()
767
- retryHandler.isRetryableResult = true
768
- retryHandler.attemptRetryDelayNs = 1_000_000_000
769
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
770
- let coordinator = PlaybackCoordinator(
771
- errorHandler: errorHandler, sleepTimerManager: MockSleepTimerHandling(),
772
- )
773
- let effectHandler = MockPlaybackEffectHandler()
774
- coordinator.effectHandler = effectHandler
775
- coordinator.healthyPlaybackDuration = 0.01
773
+ let (player, retry, _) = await makeAfterOneRetry(delay: 1)
774
+ player.healthyPlaybackDuration = 0.01
776
775
 
777
- coordinator.transition(.avPlayerPlaying)
778
- errorHandler.handleError(URLError(.timedOut), context: .playback)
779
- await coordinator.healthyPlaybackTask?.value
776
+ player.transition(.avPlayerPlaying)
777
+ retry.handleError(URLError(.timedOut), context: .playback)
778
+ await player.healthyPlaybackTask?.value
780
779
 
781
- #expect(retryHandler.resetCallCount == 0)
782
- #expect(errorHandler.pendingRetryTask != nil)
780
+ #expect(retry.pendingTask != nil)
781
+ retry.cancel()
783
782
  }
784
783
 
785
784
  @Test @MainActor
786
785
  func playbackInterruptedBeforeThreshold_doesNotReset() async {
787
- let retryHandler = MockRetryHandling()
788
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
789
- let coordinator = PlaybackCoordinator(
790
- errorHandler: errorHandler, sleepTimerManager: MockSleepTimerHandling(),
791
- )
792
- let effectHandler = MockPlaybackEffectHandler()
793
- coordinator.effectHandler = effectHandler
794
- effectHandler.hasLoadedAsset = true
795
- coordinator.healthyPlaybackDuration = 0.5
786
+ let (player, retry, engine) = await makeAfterOneRetry()
787
+ engine.hasLoadedAsset = true
788
+ player.healthyPlaybackDuration = 0.5
796
789
 
797
- coordinator.transition(.avPlayerPlaying)
798
- let refill = coordinator.healthyPlaybackTask
799
- coordinator.transition(.avPlayerWaiting) // stalls before the threshold
790
+ player.transition(.avPlayerPlaying)
791
+ let refill = player.healthyPlaybackTask
792
+ player.transition(.avPlayerWaiting) // stalls before the threshold
800
793
 
801
794
  // Leaving .playing cancels the refill; awaiting the cancelled task is the
802
795
  // deterministic way to reach the point where it would have fired.
803
796
  await refill?.value
804
797
 
805
- #expect(retryHandler.resetCallCount == 0)
806
- }
807
- }
808
-
809
- @Suite("PlaybackCoordinator - now playing state")
810
- struct PlaybackCoordinatorNowPlayingStateTests {
811
- @Test @MainActor
812
- func change_updatesNowPlayingState_evenDuringLoading() {
813
- let (c, eh, _, _) = makeCoordinator()
814
- c.transition(.trackLoading) // mid track-load; start/pause is deferred
815
- eh.updateNowPlayingStateCalls.removeAll()
816
-
817
- c.playWhenReady = true
818
-
819
- // The lock-screen / CarPlay play-pause button reflects the user's play
820
- // *intent* immediately, even while the new item is still loading — otherwise
821
- // it stays stuck showing "paused" until a later state transition repairs it.
822
- #expect(eh.updateNowPlayingStateCalls.last == true)
823
- }
824
-
825
- @Test @MainActor
826
- func staysPlaying_whenBufferRunsDry() {
827
- let (c, eh, _, _) = makeCoordinator()
828
- c.playWhenReady = true
829
- c.transition(.avPlayerPlaying)
830
- eh.updateNowPlayingStateCalls.removeAll()
831
-
832
- c.transition(.avPlayerWaiting) // buffer ran dry → .buffering
833
-
834
- // A buffer underrun doesn't change play intent, so the button stays "playing"
835
- // (the system shows buffering) — it must NOT flip to paused like a user pause.
836
- #expect(eh.updateNowPlayingStateCalls.last == true)
837
- }
838
-
839
- @Test @MainActor
840
- func fallsBackToPaused_onError() {
841
- let (c, eh, _, _) = makeCoordinator()
842
- c.playWhenReady = true
843
- c.transition(.trackLoading) // active + intent → playing
844
- eh.updateNowPlayingStateCalls.removeAll()
845
-
846
- c.transition(.errorOccurred(.playbackFailed))
847
-
848
- // On a terminal error the button must fall back to paused — never stick on
849
- // "playing" — even though playWhenReady is still true.
850
- #expect(eh.updateNowPlayingStateCalls.last == false)
851
- }
852
-
853
- @Test @MainActor
854
- func change_doesNotStampNowPlayingState_fromTerminalState() {
855
- let (c, eh, _, _) = makeCoordinator()
856
- c.transition(.errorOccurred(.playbackFailed)) // terminal, not playbackActive
857
- eh.updateNowPlayingStateCalls.removeAll()
858
-
859
- c.playWhenReady = true
860
-
861
- // Must NOT stamp .playing from a terminal state: the reload may fail and no
862
- // active transition would repair a premature "playing", leaving a phantom
863
- // playing button. The reload's own .loading/.ready transition stamps it.
864
- #expect(eh.updateNowPlayingStateCalls.isEmpty)
865
- }
866
-
867
- @Test @MainActor
868
- func selectingNewTrack_whileStopped_showsPlayingInNowPlaying() {
869
- let (c, eh, _, _) = makeCoordinator()
870
- let tracks = [
871
- Track(id: "t1", src: "https://example.com/1.mp3", title: "Track 1"),
872
- Track(id: "t2", src: "https://example.com/2.mp3", title: "Track 2"),
873
- ]
874
- c.setQueue(tracks) // loads t1 (playWhenReady defaults to false)
875
- c.transition(.stopped) // user had paused/stopped
876
- eh.updateNowPlayingStateCalls.removeAll()
877
-
878
- // User selects another track, intending to play it.
879
- try? c.skipTo(1, playWhenReady: true)
880
-
881
- // Audio will play, so the now-playing button must end up "playing" — not the
882
- // stale "paused" captured before playWhenReady flipped true during loading.
883
- #expect(eh.updateNowPlayingStateCalls.last == true)
798
+ #expect(retry.attempts == 1)
884
799
  }
885
800
  }
886
801
 
887
802
  // MARK: - Sleep Timer Fade
888
803
 
889
- @Suite("PlaybackCoordinator - sleep timer fade")
804
+ @Suite("Player - sleep timer fade")
890
805
  struct SleepTimerFadeTests {
891
806
  @Test @MainActor
892
807
  func fadeStart_capturesPreFadeVolume() {
@@ -903,7 +818,7 @@ struct SleepTimerFadeTests {
903
818
  func completion_pausesThenRestoresPreFadeVolume() {
904
819
  let (c, eh, _, st) = makeCoordinator()
905
820
  loadTrack(c)
906
- c.playWhenReady = true
821
+ c.setPlayWhenReady(true)
907
822
  c.transition(.bufferingSufficient) // leave .loading so pause reaches the player
908
823
  eh.pausePlaybackCallCount = 0
909
824
 
@@ -922,7 +837,7 @@ struct SleepTimerFadeTests {
922
837
  func completion_withoutFade_justPauses() {
923
838
  let (c, eh, _, st) = makeCoordinator()
924
839
  loadTrack(c)
925
- c.playWhenReady = true
840
+ c.setPlayWhenReady(true)
926
841
  eh.volume = 0.8
927
842
 
928
843
  st.onComplete?()
@@ -949,38 +864,41 @@ struct SleepTimerFadeTests {
949
864
  func pauseDuringFade_clearsSleepTimer() {
950
865
  let (c, _, _, st) = makeCoordinator()
951
866
  loadTrack(c)
952
- c.playWhenReady = true
867
+ c.setPlayWhenReady(true)
868
+ st.set(seconds: 60, fadeDuration: 10)
953
869
 
954
870
  st.onFadeStart?(10)
955
871
  c.pause()
956
872
 
957
- #expect(st.clearCallCount == 1)
873
+ #expect(st.get() == nil)
958
874
  }
959
875
 
960
876
  @Test @MainActor
961
877
  func pauseWithoutFade_leavesSleepTimerAlone() {
962
878
  let (c, _, _, st) = makeCoordinator()
963
879
  loadTrack(c)
964
- c.playWhenReady = true
880
+ c.setPlayWhenReady(true)
881
+ st.set(seconds: 60, fadeDuration: 10)
965
882
 
966
883
  c.pause()
967
884
 
968
- #expect(st.clearCallCount == 0)
885
+ #expect(st.get() != nil)
969
886
  }
970
887
  }
971
888
 
972
889
  // MARK: - Audio Session Interruptions
973
890
 
974
- /// Helper to drive the coordinator into an actively-playing state.
891
+ /// Helper to drive the player into an actively-playing state.
975
892
  @MainActor
976
- private func startPlaying(_ c: PlaybackCoordinator, _ eh: MockPlaybackEffectHandler) {
893
+ private func startPlaying(_ c: Player, _ eh: MockPlayerEngine) {
977
894
  loadTrack(c)
978
895
  eh.hasLoadedAsset = true
979
- c.playWhenReady = true
896
+ eh.hasItem = true
897
+ c.setPlayWhenReady(true)
980
898
  c.transition(.avPlayerPlaying)
981
899
  }
982
900
 
983
- @Suite("PlaybackCoordinator - audio session interruptions")
901
+ @Suite("Player - audio session interruptions")
984
902
  struct InterruptionTests {
985
903
  @Test @MainActor
986
904
  func began_whilePlaying_pauses() {
@@ -1090,13 +1008,14 @@ struct InterruptionTests {
1090
1008
  let (c, eh, _, _) = makeCoordinator()
1091
1009
  startPlaying(c, eh)
1092
1010
  c.handleInterruptionBegan()
1011
+ let resolvesBefore = eh.resolveAndLoadCalls.count
1093
1012
 
1094
1013
  c.stop()
1095
1014
  c.handleInterruptionEnded(shouldResume: true)
1096
1015
 
1097
1016
  #expect(c.state == .stopped)
1098
1017
  #expect(c.playWhenReady == false)
1099
- #expect(eh.reloadTrackCalls.isEmpty)
1018
+ #expect(eh.resolveAndLoadCalls.count == resolvesBefore)
1100
1019
  }
1101
1020
 
1102
1021
  /// A user who plays then pauses during the interruption has taken ownership
@@ -1119,7 +1038,7 @@ struct InterruptionTests {
1119
1038
  let (c, eh, _, _) = makeCoordinator()
1120
1039
  startPlaying(c, eh)
1121
1040
  // Pause before the interruption (e.g. user already paused).
1122
- c.playWhenReady = false
1041
+ c.setPlayWhenReady(false)
1123
1042
  c.avPlayerDidChangeTimeControlStatus(.paused)
1124
1043
  #expect(c.state == .paused)
1125
1044
 
@@ -1132,10 +1051,9 @@ struct InterruptionTests {
1132
1051
 
1133
1052
  // MARK: - Skip availability freshness
1134
1053
 
1135
- /// tracks.didSet pushes availability mid-mutation, while currentIndex and the
1136
- /// shuffle order are still stale — the coordinator must re-push after the
1137
- /// mutation settles or the lock-screen buttons keep the wrong state.
1138
- @Suite("PlaybackCoordinator - skip availability after mutations")
1054
+ /// A mutation pushes next/previous availability once, after currentIndex and the
1055
+ /// shuffle order have settled; an earlier push leaves the lock-screen buttons wrong.
1056
+ @Suite("Player - skip availability after mutations")
1139
1057
  struct MutationSkipAvailabilityTests {
1140
1058
  @Test @MainActor
1141
1059
  func move_pushesPostMutationAvailability() throws {
@@ -1149,6 +1067,7 @@ struct MutationSkipAvailabilityTests {
1149
1067
  try c.move(fromIndex: 0, toIndex: 1)
1150
1068
 
1151
1069
  // Current track is now last: nothing to skip to.
1070
+ #expect(eh.updateSkipAvailabilityCalls.count == 1)
1152
1071
  #expect(eh.updateSkipAvailabilityCalls.last?.canNext == false)
1153
1072
  }
1154
1073
 
@@ -1167,49 +1086,180 @@ struct MutationSkipAvailabilityTests {
1167
1086
  try c.remove(0)
1168
1087
 
1169
1088
  // Current settled at index 1 of 2: previous available, next not.
1089
+ #expect(eh.updateSkipAvailabilityCalls.count == 1)
1170
1090
  #expect(eh.updateSkipAvailabilityCalls.last?.canNext == false)
1171
1091
  #expect(eh.updateSkipAvailabilityCalls.last?.canPrevious == true)
1172
1092
  }
1173
1093
  }
1174
1094
 
1095
+ // MARK: - Publishing
1096
+
1097
+ @Suite("Player - publishing")
1098
+ struct PublishingTests {
1099
+ /// The order JS has always seen: the intent, then the playing state, then the playback. A
1100
+ /// natural end clears the intent on the way in; each change still goes out once.
1101
+ @Test @MainActor
1102
+ func endingPublishesTheIntentThenThePlayingStateThenThePlayback() {
1103
+ let (c, _, cb, _) = makeCoordinator()
1104
+ c.setPlayWhenReady(true)
1105
+ c.transition(.avPlayerPlaying)
1106
+ cb.publishOrder.removeAll()
1107
+
1108
+ c.transition(.trackEndedNaturally)
1109
+
1110
+ #expect(cb.publishOrder == ["playWhenReady", "playingState", "playback"])
1111
+ }
1112
+
1113
+ @Test @MainActor
1114
+ func aTransitionPublishesThePlayingStateBeforeThePlayback() {
1115
+ let (c, _, cb, _) = makeCoordinator()
1116
+ c.setPlayWhenReady(true)
1117
+ cb.publishOrder.removeAll()
1118
+
1119
+ c.transition(.avPlayerPlaying)
1120
+
1121
+ #expect(cb.publishOrder == ["playingState", "playback"])
1122
+ }
1123
+
1124
+ @Test @MainActor
1125
+ func aRepeatedTerminalErrorIsReportedAgain() {
1126
+ let (c, _, cb, _) = makeCoordinator()
1127
+ c.transition(.errorOccurred(.playbackFailed))
1128
+ c.transition(.errorOccurred(.playbackFailed))
1129
+
1130
+ #expect(cb.errorEvents.count == 2)
1131
+ }
1132
+ }
1133
+
1134
+ // MARK: - Load
1135
+
1136
+ @Suite("Player - Load")
1137
+ struct LoadTests {
1138
+ @MainActor
1139
+ private func track(_ id: String) -> Track {
1140
+ Track(id: id, src: "https://example.com/\(id).mp3", title: id)
1141
+ }
1142
+
1143
+ @Test @MainActor
1144
+ func aNewTrackStartsAnUnprovenLoad() {
1145
+ let (c, _, _, _) = makeCoordinator()
1146
+ c.setQueue([track("a"), track("b")])
1147
+ let first = c.load
1148
+ c.audioDidStart()
1149
+ #expect(c.load?.hasPlayed == true)
1150
+
1151
+ c.next()
1152
+
1153
+ #expect(c.load != nil && c.load?.id != first?.id)
1154
+ #expect(c.load?.hasPlayed == false)
1155
+ }
1156
+
1157
+ /// The healthy-playback refill resets the retry budgets mid-play; the load, and with it the
1158
+ /// recovery budget, must survive it.
1159
+ @Test @MainActor
1160
+ func aRetryResetKeepsTheLoad() {
1161
+ let (c, _, _, _) = makeCoordinator()
1162
+ c.setQueue([track("a")])
1163
+ c.audioDidStart()
1164
+ let load = c.load
1165
+
1166
+ c.retry.reset()
1167
+
1168
+ #expect(c.load == load)
1169
+ #expect(c.load?.hasPlayed == true)
1170
+ }
1171
+
1172
+ @Test @MainActor
1173
+ func theNowPlayingPrimeRunsOncePerLoad() {
1174
+ let (c, _, _, _) = makeCoordinator()
1175
+ c.setQueue([track("a")])
1176
+ #expect(c.claimNowPlayingPrime())
1177
+ #expect(!c.claimNowPlayingPrime())
1178
+
1179
+ c.beginLoad()
1180
+
1181
+ #expect(c.claimNowPlayingPrime())
1182
+ }
1183
+ }
1184
+
1185
+ // MARK: - Queue publication
1186
+
1187
+ @Suite("Player - queue publication")
1188
+ struct QueuePublicationTests {
1189
+ @MainActor
1190
+ private func threeTracks() -> [Track] {
1191
+ ["a", "b", "c"].map { Track(id: $0, src: "https://example.com/\($0).mp3", title: $0) }
1192
+ }
1193
+
1194
+ @Test @MainActor
1195
+ func setQueue_publishesTheQueueBeforeTheActiveTrack() {
1196
+ let (c, _, cb, _) = makeCoordinator()
1197
+ c.setQueue(threeTracks())
1198
+ #expect(cb.queueChanges.map { $0.map(\.id) } == [["a", "b", "c"]])
1199
+ #expect(cb.queueAndTrackOrder == ["queue", "activeTrack"])
1200
+ }
1201
+
1202
+ @Test @MainActor
1203
+ func move_publishesTheSettledQueueOnce() throws {
1204
+ let (c, _, cb, _) = makeCoordinator()
1205
+ c.setQueue(threeTracks())
1206
+ cb.queueChanges.removeAll()
1207
+ try c.move(fromIndex: 0, toIndex: 2)
1208
+ #expect(cb.queueChanges.map { $0.map(\.id) } == [["b", "c", "a"]])
1209
+ }
1210
+
1211
+ @Test @MainActor
1212
+ func clear_publishesAnEmptyQueue() {
1213
+ let (c, _, cb, _) = makeCoordinator()
1214
+ c.setQueue(threeTracks())
1215
+ cb.queueChanges.removeAll()
1216
+ c.clear()
1217
+ #expect(cb.queueChanges.map(\.count) == [0])
1218
+ }
1219
+
1220
+ @Test @MainActor
1221
+ func navigation_publishesNoQueue() throws {
1222
+ let (c, _, cb, _) = makeCoordinator()
1223
+ c.setQueue(threeTracks())
1224
+ cb.queueChanges.removeAll()
1225
+ c.next()
1226
+ c.previous()
1227
+ try c.skipTo(2)
1228
+ #expect(cb.queueChanges.isEmpty)
1229
+ }
1230
+ }
1231
+
1175
1232
  // MARK: - Stop cancels pending retry
1176
1233
 
1177
- @Suite("PlaybackCoordinator - stop cancels pending retry")
1234
+ @Suite("Player - stop cancels pending retry")
1178
1235
  struct StopCancelsRetryTests {
1179
1236
  /// A retry surviving stop() gives up later (intent dropped) and surfaced
1180
1237
  /// .errorOccurred over the deliberate stop — the UI showed an error seconds
1181
1238
  /// after the user stopped.
1182
1239
  @Test @MainActor
1183
1240
  func stop_cancelsPendingRetry_andStaysSilent() async {
1184
- let retryHandler = MockRetryHandling()
1185
- retryHandler.isRetryableResult = true
1186
- retryHandler.attemptRetryResult = false
1187
- retryHandler.attemptRetryDelayNs = 50_000_000
1188
- let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
1189
- let coordinator = PlaybackCoordinator(
1190
- errorHandler: errorHandler, sleepTimerManager: MockSleepTimerHandling(),
1191
- )
1192
- let effectHandler = MockPlaybackEffectHandler()
1193
- let callbacks = MockPlaybackCoordinatorCallbacks()
1194
- coordinator.effectHandler = effectHandler
1195
- coordinator.callbacks = callbacks
1241
+ let errorHandler = makeRetryLoop(delay: 0.05)
1242
+ let engine = MockPlayerEngine()
1243
+ let player = makePlayer(engine: engine, retry: errorHandler)
1244
+ let callbacks = MockPlayerCallbacks()
1245
+ player.callbacks = callbacks
1196
1246
 
1197
1247
  errorHandler.handleError(URLError(.timedOut), context: .playback)
1198
- let task = errorHandler.pendingRetryTask
1248
+ let task = errorHandler.pendingTask
1199
1249
  #expect(task != nil)
1200
1250
 
1201
- coordinator.stop()
1202
- #expect(errorHandler.pendingRetryTask == nil)
1251
+ player.stop()
1252
+ #expect(errorHandler.pendingTask == nil)
1203
1253
 
1204
1254
  await task?.value
1205
- #expect(coordinator.state == .stopped)
1255
+ #expect(player.state == .stopped)
1206
1256
  #expect(callbacks.errorEvents.isEmpty)
1207
1257
  }
1208
1258
  }
1209
1259
 
1210
1260
  // MARK: - Audio session release (#60)
1211
1261
 
1212
- @Suite("PlaybackCoordinator - audio session release")
1262
+ @Suite("Player - audio session release")
1213
1263
  struct SessionReleaseTests {
1214
1264
  @Test @MainActor
1215
1265
  func deliberatePause_requestsRelease() {