react-native-audio-browser 0.2.0-next.27 → 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 (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,405 @@
1
+ @testable import AudioBrowserTestable
2
+ import Foundation
3
+ import Testing
4
+
5
+ /// The decisions the Player makes over its engine: seeking, reloading, recovering, and what an
6
+ /// engine event leads to.
7
+ @MainActor
8
+ private func makeLoading(
9
+ src: String? = "https://example.com/a.mp3",
10
+ live: Bool = false,
11
+ ) -> (Player, MockPlayerEngine, MockPlayerCallbacks) {
12
+ let engine = MockPlayerEngine()
13
+ let player = makePlayer(engine: engine, retries: false)
14
+ let callbacks = MockPlayerCallbacks()
15
+ player.callbacks = callbacks
16
+ player.setQueue([Track(id: "t1", src: src, title: "T", live: live)])
17
+ return (player, engine, callbacks)
18
+ }
19
+
20
+ /// Loaded and playing: the item is in place and audio has started.
21
+ @MainActor
22
+ private func makePlaying(live: Bool = false) -> (Player, MockPlayerEngine, MockPlayerCallbacks) {
23
+ let (player, engine, callbacks) = makeLoading(live: live)
24
+ player.setPlayWhenReady(true)
25
+ engine.hasItem = true
26
+ engine.hasLoadedAsset = true
27
+ engine.emit(.itemPrepared(likelyToKeepUp: true))
28
+ player.transition(.avPlayerPlaying)
29
+ return (player, engine, callbacks)
30
+ }
31
+
32
+ @Suite("Player - seeking")
33
+ @MainActor
34
+ struct PlayerSeekingTests {
35
+ @Test func aSeekWhileLoadingWaitsForTheItem_andHoldsReadyUntilItLands() {
36
+ let (player, engine, _) = makeLoading()
37
+ engine.seekResult = nil
38
+ player.seek(to: 30)
39
+ #expect(engine.seekCalls.isEmpty)
40
+
41
+ engine.hasItem = true
42
+ engine.hasLoadedAsset = true
43
+ engine.emit(.itemPrepared(likelyToKeepUp: true))
44
+ #expect(engine.seekCalls == [30])
45
+ #expect(player.state == .loading)
46
+ }
47
+
48
+ @Test func theLoadBecomesReadyWhenTheDeferredSeekLands() {
49
+ let (player, engine, _) = makeLoading()
50
+ player.seek(to: 30)
51
+ engine.hasItem = true
52
+ engine.hasLoadedAsset = true
53
+ engine.emit(.itemPrepared(likelyToKeepUp: false))
54
+ #expect(engine.seekCalls == [30])
55
+ #expect(player.state == .ready)
56
+ }
57
+
58
+ @Test func seekByWhileLoadingOffsetsTheDeferredTarget() {
59
+ let (player, _, _) = makeLoading()
60
+ player.seek(to: 30)
61
+ player.seek(by: 15)
62
+ #expect(player.deferredSeek.target == 45)
63
+ }
64
+
65
+ /// A completion from before a track change belongs to the old item: it reports false and
66
+ /// leaves the new track's start position pending.
67
+ @Test func aCompletionFromBeforeATrackChangeIsDropped() {
68
+ let (player, engine, _) = makePlaying()
69
+ engine.seekResult = nil
70
+ var landed: [Bool] = []
71
+ player.seek(to: 10) { landed.append($0) }
72
+
73
+ player.setQueue([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")], startPositionMs: 25000)
74
+ engine.completeSeeks(true)
75
+
76
+ #expect(landed == [false])
77
+ #expect(player.deferredSeek.target == 25)
78
+ #expect(player.state == .loading)
79
+ }
80
+
81
+ @Test func theLiveEdgeIsTheSeekableWindowsEnd_orAReconnectWithoutOne() {
82
+ let (player, engine, _) = makePlaying(live: true)
83
+ engine.liveEdge = 120
84
+ player.seekToLiveEdge()
85
+ #expect(engine.seekCalls == [120])
86
+
87
+ engine.liveEdge = nil
88
+ player.seekToLiveEdge()
89
+ #expect(engine.resolveAndLoadCalls.count == 2)
90
+ #expect(player.state == .loading)
91
+ }
92
+ }
93
+
94
+ @Suite("Player - engine events")
95
+ @MainActor
96
+ struct PlayerEngineEventTests {
97
+ @Test func aFailedLoadPrimesNowPlayingOncePerLoad() {
98
+ let (player, engine, _) = makeLoading()
99
+ player.setPlayWhenReady(true)
100
+ player.retry.shouldRetry = { true }
101
+ engine.emit(.loadFailed(URLError(.timedOut)))
102
+ engine.emit(.loadFailed(URLError(.timedOut)))
103
+ #expect(engine.primeCount == 1)
104
+
105
+ player.setQueue([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
106
+ engine.emit(.loadFailed(URLError(.timedOut)))
107
+ #expect(engine.primeCount == 2)
108
+ }
109
+
110
+ @Test func aFailedLoadWithoutIntentDoesNotPrime() async {
111
+ let (player, engine, _) = makeLoading()
112
+ engine.emit(.loadFailed(URLError(.timedOut)))
113
+ await player.retry.pendingTask?.value
114
+ #expect(engine.primeCount == 0)
115
+ #expect(player.state == .error)
116
+ }
117
+
118
+ @Test func anUnplayableAssetIsTerminal() {
119
+ let (player, engine, _) = makeLoading()
120
+ engine.emit(.unplayable)
121
+ #expect(player.state == .error)
122
+ #expect(player.errorStatus == .terminal(.trackWasUnplayable))
123
+ }
124
+
125
+ @Test func anInvalidSourceIsTerminal() {
126
+ let (player, engine, _) = makeLoading()
127
+ engine.emit(.invalidSource("::"))
128
+ #expect(player.errorStatus == .terminal(.invalidSourceUrl("::")))
129
+ }
130
+
131
+ @Test func aPlayerFailureCarriesItsHTTPStatus() {
132
+ let (player, engine, _) = makePlaying()
133
+ engine.emit(.failed(nil, httpStatusCode: 404))
134
+ #expect(player.errorStatus == .terminal(.httpStatus(404)))
135
+ }
136
+
137
+ @Test func aGenuineEndAdvances_aLiveEndReconnects() {
138
+ let (player, engine, _) = makePlaying()
139
+ engine.currentTime = 300
140
+ engine.duration = 300
141
+ engine.emit(.playedToEnd)
142
+ #expect(player.state == .ended)
143
+
144
+ let (live, liveEngine, _) = makePlaying(live: true)
145
+ liveEngine.emit(.playedToEnd)
146
+ #expect(live.state == .loading)
147
+ #expect(liveEngine.resolveAndLoadCalls.count == 2)
148
+ }
149
+
150
+ @Test func aStallNudgesPlayback_onlyWithIntent() {
151
+ let (player, engine, _) = makePlaying()
152
+ engine.startPlaybackCallCount = 0
153
+ engine.emit(.stalled)
154
+ #expect(engine.startPlaybackCallCount == 1)
155
+
156
+ player.pause()
157
+ engine.startPlaybackCallCount = 0
158
+ engine.emit(.stalled)
159
+ #expect(engine.startPlaybackCallCount == 0)
160
+ }
161
+
162
+ @Test func anAirPlayStrandingIsNudged() {
163
+ let (player, engine, _) = makePlaying()
164
+ engine.isAirPlayRoute = true
165
+ engine.emit(.timeControlStatus(.waitingToPlayAtSpecifiedRate, waitingForNoItem: true))
166
+ engine.startPlaybackCallCount = 0
167
+ engine.emit(.timeControlStatus(.paused, waitingForNoItem: false))
168
+ #expect(engine.startPlaybackCallCount == 1)
169
+ #expect(player.playWhenReady)
170
+ }
171
+ }
172
+
173
+ @Suite("Player - recovering")
174
+ @MainActor
175
+ struct PlayerRecoveringTests {
176
+ @Test func theNetworkReturningReconnectsAStalledStream() {
177
+ let (player, engine, _) = makePlaying()
178
+ player.transition(.avPlayerWaiting)
179
+ #expect(player.state == .buffering)
180
+ player.networkDidReturn()
181
+ #expect(engine.resolveAndLoadCalls.count == 2)
182
+ }
183
+
184
+ @Test func theNetworkReturningLeavesAPausedOrPlayingStreamAlone() {
185
+ let (player, engine, _) = makePlaying()
186
+ player.networkDidReturn()
187
+ player.pause()
188
+ player.networkDidReturn()
189
+ #expect(engine.resolveAndLoadCalls.count == 1)
190
+ }
191
+
192
+ /// A restart from a terminal error is a new Load: fresh retry budgets, unproven playback, the
193
+ /// primer allowed again; a retry within a Load keeps its Load.
194
+ @Test func aRestartFromAnErrorBeginsANewLoad() async {
195
+ let (player, engine, _) = makePlaying()
196
+ engine.emit(.audioDidStart)
197
+ let first = player.load
198
+ #expect(first?.hasPlayed == true)
199
+
200
+ engine.emit(.unplayable)
201
+ #expect(player.state == .error)
202
+ player.play()
203
+
204
+ #expect(player.load?.id != first?.id)
205
+ #expect(player.load?.hasPlayed == false)
206
+ #expect(player.load?.primedNowPlaying == false)
207
+ #expect(engine.recreateCount == 1)
208
+ }
209
+
210
+ @Test func aRetryKeepsItsLoad() async {
211
+ let (player, engine, _) = makePlaying()
212
+ player.retry.shouldRetry = { true }
213
+ let first = player.load
214
+ engine.emit(.failed(URLError(.timedOut), httpStatusCode: nil))
215
+ await player.retry.pendingTask?.value
216
+ #expect(player.load?.id == first?.id)
217
+ #expect(engine.recreateCount == 0)
218
+ }
219
+
220
+ @Test func aMediaServicesResetReloadsOnANewPlayer() {
221
+ let (player, engine, _) = makePlaying()
222
+ player.mediaServicesWereReset()
223
+ #expect(engine.recreateCount == 1)
224
+ #expect(engine.resolveAndLoadCalls.count == 2)
225
+ #expect(player.playWhenReady)
226
+ }
227
+
228
+ @Test func aStopRewindsAFiniteTrack_notALiveOne() {
229
+ let (player, engine, _) = makePlaying()
230
+ player.stop()
231
+ #expect(engine.seekCalls == [0])
232
+
233
+ let (live, liveEngine, _) = makePlaying(live: true)
234
+ live.stop()
235
+ #expect(liveEngine.seekCalls.isEmpty)
236
+ }
237
+
238
+ @Test func aNonPositiveRateIsRefused() {
239
+ let (player, engine, _) = makePlaying()
240
+ player.rate = 1.5
241
+ player.rate = 0
242
+ #expect(player.rate == 1.5)
243
+ #expect(engine.rates.map(\.rate) == [1.5])
244
+ #expect(engine.rates.last?.playing == true)
245
+ }
246
+
247
+ @Test func isStalledNeedsProgressIntentAndAThinBuffer() {
248
+ let (player, engine, _) = makePlaying()
249
+ engine.currentTime = 10
250
+ engine.isPlaybackLikelyToKeepUp = false
251
+ #expect(player.isStalled)
252
+ engine.currentTime = 0.2
253
+ #expect(!player.isStalled)
254
+ }
255
+ }
256
+
257
+ @Suite("Player - review regressions", .serialized)
258
+ @MainActor
259
+ struct PlayerReviewRegressionTests {
260
+ /// An end-of-track sleep timer drops the intent while still .playing; the snapshot must still
261
+ /// be rewritten on entering .ended, or a cold resume seeks to the end and re-ends.
262
+ @Test func endingWithTheIntentAlreadyOffSavesPositionZero() {
263
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
264
+ let (player, engine, _) = makePlaying()
265
+ engine.currentTime = 300
266
+ engine.duration = 300
267
+ player.pause()
268
+ player.transition(.trackEndedNaturally)
269
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
270
+ #expect(saved?.positionMs == 0)
271
+ }
272
+
273
+ /// A track change while paused saves the new track at its start, not at the old item's
274
+ /// position, which the engine still reports until the new item is prepared.
275
+ @Test func skippingWhilePausedSavesTheNewTrackAtItsStart() {
276
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
277
+ let (player, engine, _) = makePlaying()
278
+ player.add([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
279
+ player.pause()
280
+ engine.currentTime = 1800
281
+ player.next()
282
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
283
+ #expect(saved?.track.id == "t2")
284
+ #expect(saved?.positionMs == 0)
285
+ }
286
+
287
+ /// A reload while paused holds its resume position before the loading transition saves the
288
+ /// snapshot.
289
+ @Test func aRetryWhilePausedSavesWhereItWas() {
290
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
291
+ let (player, engine, _) = makePlaying()
292
+ player.pause()
293
+ engine.currentTime = 120
294
+ engine.resumePosition = 120
295
+ player.reloadResolving(startFromCurrentTime: true)
296
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
297
+ #expect(saved?.positionMs == 120_000)
298
+ #expect(player.deferredSeek.target == 120)
299
+ }
300
+
301
+ /// A skip whose load fails leaves the previous item in the engine: the snapshot and the restart
302
+ /// from the error start the new track at its start, not at the old one's position.
303
+ @Test func aFailedSkipKeepsTheNewTrackAtItsStart() {
304
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
305
+ let (player, engine, _) = makePlaying()
306
+ player.add([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
307
+ engine.currentTime = 1800
308
+ engine.resumePosition = 1800
309
+ player.next()
310
+ engine.emit(.unplayable)
311
+ #expect(player.state == .error)
312
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
313
+ #expect(saved?.track.id == "t2")
314
+ #expect(saved?.positionMs == 0)
315
+
316
+ player.play()
317
+ #expect(player.state == .loading)
318
+ #expect(player.deferredSeek.target == nil)
319
+ }
320
+
321
+ /// A queue's start position is in the snapshot the track change saves, not only in the next
322
+ /// periodic save.
323
+ @Test func aStartPositionIsSavedWithTheTrackChange() {
324
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
325
+ let player = makePlayer(retries: false)
326
+ player.setQueue([Track(id: "t1", src: "https://example.com/a.mp3", title: "A")], startPositionMs: 30000)
327
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
328
+ #expect(saved?.positionMs == 30000)
329
+ #expect(player.deferredSeek.target == 30)
330
+ }
331
+
332
+ /// While nothing plays, a transition alone rewrites the snapshot.
333
+ @Test func anErrorWhilePausedSavesWhereItStopped() {
334
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
335
+ let (player, engine, _) = makePlaying()
336
+ player.pause()
337
+ engine.currentTime = 42
338
+ engine.emit(.failed(nil, httpStatusCode: nil))
339
+ #expect(player.state == .error)
340
+ let saved = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }.load()
341
+ #expect(saved?.positionMs == 42000)
342
+ }
343
+
344
+ /// An active play intent saves the snapshot periodically.
345
+ @Test func playingSavesPeriodically() async throws {
346
+ UserDefaults(suiteName: "PlayerTests")!.removePersistentDomain(forName: "PlayerTests")
347
+ let engine = MockPlayerEngine()
348
+ let player = makePlayer(engine: engine, retries: false, saveInterval: .milliseconds(10))
349
+ player.setQueue([Track(id: "t1", src: "https://example.com/a.mp3", title: "T")], playWhenReady: true)
350
+ engine.hasItem = true
351
+ engine.hasLoadedAsset = true
352
+ engine.emit(.itemPrepared(likelyToKeepUp: true))
353
+ player.transition(.avPlayerPlaying)
354
+ engine.currentTime = 7
355
+ let persistence = PlaybackPersistence(defaults: UserDefaults(suiteName: "PlayerTests")!) { nil }
356
+ for _ in 0 ..< 100 where persistence.load()?.positionMs != 7000 {
357
+ try await Task.sleep(for: .milliseconds(10))
358
+ }
359
+ #expect(persistence.load()?.positionMs == 7000)
360
+ player.pause()
361
+ }
362
+
363
+ /// A persisted session resumes its settings, then its track at the saved position.
364
+ @Test func aPersistedSessionResumesWhereItWasSaved() throws {
365
+ let defaults = UserDefaults(suiteName: "PlayerTests")!
366
+ defaults.removePersistentDomain(forName: "PlayerTests")
367
+ let saved = PersistedPlaybackState(
368
+ track: JsonTrack(from: Track(id: "t1", src: "https://example.com/a.mp3", title: "A")),
369
+ positionMs: 30000, repeatMode: "queue", shuffleEnabled: false, playbackSpeed: 1.5,
370
+ )
371
+ PlaybackPersistence(defaults: defaults) { saved }.save()
372
+
373
+ let player = makePlayer(retries: false)
374
+ let session = try #require(player.persistedSession())
375
+ player.resume(session, queue: nil)
376
+ #expect(player.currentTrack?.id == "t1")
377
+ #expect(player.deferredSeek.target == 30)
378
+ #expect(player.repeatMode == .queue)
379
+ #expect(player.rate == 1.5)
380
+ #expect(player.playWhenReady)
381
+ }
382
+
383
+ @Test func aDisabledPersistedTrackIsNotResumed() {
384
+ let defaults = UserDefaults(suiteName: "PlayerTests")!
385
+ defaults.removePersistentDomain(forName: "PlayerTests")
386
+ let saved = PersistedPlaybackState(
387
+ track: JsonTrack(from: Track(id: "t1", src: "https://example.com/a.mp3", title: "A", disabled: true)),
388
+ positionMs: nil, repeatMode: "off", shuffleEnabled: false, playbackSpeed: 1,
389
+ )
390
+ PlaybackPersistence(defaults: defaults) { saved }.save()
391
+ #expect(makePlayer(retries: false).persistedSession() == nil)
392
+ }
393
+
394
+ /// Leaving an errored track publishes the new track's loading state once, never an error
395
+ /// state without its error.
396
+ @Test func skippingAwayFromAnErrorNeverPublishesAnErrorlessError() {
397
+ let (player, engine, callbacks) = makeLoading()
398
+ engine.emit(.unplayable)
399
+ callbacks.playbackChanges.removeAll()
400
+ callbacks.errorEvents.removeAll()
401
+ player.setQueue([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
402
+ #expect(!callbacks.playbackChanges.contains { $0.state == .error })
403
+ #expect(callbacks.errorEvents.count == 1)
404
+ }
405
+ }