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.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /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
|
|
5
|
+
/// Helper to build a player with mocks for testing.
|
|
6
6
|
@MainActor
|
|
7
7
|
private func makeCoordinator() -> (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
callbacks:
|
|
11
|
-
sleepTimer:
|
|
8
|
+
player: Player,
|
|
9
|
+
engine: MockPlayerEngine,
|
|
10
|
+
callbacks: MockPlayerCallbacks,
|
|
11
|
+
sleepTimer: SleepTimerManager,
|
|
12
12
|
) {
|
|
13
|
-
let
|
|
14
|
-
let
|
|
15
|
-
let
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
_
|
|
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
|
-
|
|
30
|
+
player.setQueue([track])
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
// MARK: - Transition + Side Effects
|
|
37
34
|
|
|
38
|
-
@Suite("
|
|
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.
|
|
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.
|
|
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.
|
|
84
|
+
#expect(c.errorStatus.error == .playbackFailed)
|
|
88
85
|
|
|
89
86
|
c.transition(.trackLoading)
|
|
90
87
|
|
|
91
88
|
#expect(c.state == .loading)
|
|
92
|
-
#expect(c.
|
|
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.
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
117
|
+
Player, RetryLoop, MockPlayerCallbacks,
|
|
118
|
+
MockPlayerEngine,
|
|
124
119
|
) {
|
|
125
|
-
let retryHandler =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let
|
|
129
|
-
|
|
130
|
-
|
|
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("
|
|
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.
|
|
136
|
+
c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
148
137
|
|
|
149
138
|
#expect(c.state == .loading)
|
|
150
|
-
#expect(c.
|
|
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.
|
|
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.
|
|
168
|
-
await c.
|
|
169
|
-
c.
|
|
170
|
-
await c.
|
|
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.
|
|
172
|
+
c.retry.handleError(URLError(.cannotConnectToHost), context: .playback)
|
|
184
173
|
|
|
185
|
-
#expect(c.
|
|
174
|
+
#expect(c.errorStatus.error == nil)
|
|
186
175
|
#expect(cb.playbackChanges.isEmpty)
|
|
187
|
-
await c.
|
|
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.
|
|
197
|
-
await c.
|
|
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.
|
|
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.
|
|
213
|
-
await c.
|
|
201
|
+
c.retry.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
202
|
+
await c.retry.pendingTask?.value
|
|
214
203
|
|
|
215
|
-
retryHandler.
|
|
216
|
-
c.
|
|
217
|
-
await c.
|
|
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("
|
|
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("
|
|
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.
|
|
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.
|
|
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("
|
|
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.
|
|
417
|
+
c.setPlayWhenReady(true)
|
|
425
418
|
|
|
426
|
-
|
|
427
|
-
#expect(eh.
|
|
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.
|
|
432
|
+
c.setPlayWhenReady(true)
|
|
436
433
|
|
|
437
|
-
#expect(eh.
|
|
438
|
-
#expect(eh.
|
|
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.
|
|
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.
|
|
453
|
+
c.setPlayWhenReady(true)
|
|
456
454
|
eh.pausePlaybackCallCount = 0
|
|
457
455
|
|
|
458
|
-
c.
|
|
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.
|
|
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.
|
|
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("
|
|
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("
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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("
|
|
596
|
+
@Suite("Player - handleTrackDidPlayToEndTime")
|
|
599
597
|
struct HandleTrackDidPlayToEndTimeTests {
|
|
600
598
|
@Test @MainActor
|
|
601
599
|
func repeatTrack_replays() {
|
|
602
600
|
let (c, eh, _, _) = makeCoordinator()
|
|
603
|
-
|
|
601
|
+
startPlaying(c, eh)
|
|
604
602
|
c.repeatMode = .track
|
|
605
|
-
|
|
603
|
+
eh.startPlaybackCallCount = 0
|
|
606
604
|
|
|
607
605
|
c.handleTrackDidPlayToEndTime()
|
|
608
606
|
|
|
609
|
-
|
|
610
|
-
#expect(eh.
|
|
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.
|
|
621
|
+
c.setPlayWhenReady(false)
|
|
624
622
|
|
|
625
623
|
c.handleTrackDidPlayToEndTime()
|
|
626
624
|
|
|
627
625
|
#expect(cb.queueEndedEvents.isEmpty)
|
|
628
|
-
#expect(eh.
|
|
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("
|
|
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.
|
|
697
|
+
eh.resumePosition = 300
|
|
698
|
+
let resolvesBefore = eh.resolveAndLoadCalls.count
|
|
701
699
|
|
|
702
700
|
c.play()
|
|
703
701
|
|
|
704
|
-
#expect(eh.
|
|
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.
|
|
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.
|
|
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("
|
|
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
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
757
|
-
await
|
|
763
|
+
player.transition(.avPlayerPlaying)
|
|
764
|
+
await player.healthyPlaybackTask?.value
|
|
758
765
|
|
|
759
|
-
#expect(
|
|
766
|
+
#expect(retry.attempts == 0)
|
|
760
767
|
}
|
|
761
768
|
|
|
762
|
-
///
|
|
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
|
|
767
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
await
|
|
776
|
+
player.transition(.avPlayerPlaying)
|
|
777
|
+
retry.handleError(URLError(.timedOut), context: .playback)
|
|
778
|
+
await player.healthyPlaybackTask?.value
|
|
780
779
|
|
|
781
|
-
#expect(
|
|
782
|
-
|
|
780
|
+
#expect(retry.pendingTask != nil)
|
|
781
|
+
retry.cancel()
|
|
783
782
|
}
|
|
784
783
|
|
|
785
784
|
@Test @MainActor
|
|
786
785
|
func playbackInterruptedBeforeThreshold_doesNotReset() async {
|
|
787
|
-
let
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
798
|
-
let refill =
|
|
799
|
-
|
|
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(
|
|
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("
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
880
|
+
c.setPlayWhenReady(true)
|
|
881
|
+
st.set(seconds: 60, fadeDuration: 10)
|
|
965
882
|
|
|
966
883
|
c.pause()
|
|
967
884
|
|
|
968
|
-
#expect(st.
|
|
885
|
+
#expect(st.get() != nil)
|
|
969
886
|
}
|
|
970
887
|
}
|
|
971
888
|
|
|
972
889
|
// MARK: - Audio Session Interruptions
|
|
973
890
|
|
|
974
|
-
/// Helper to drive the
|
|
891
|
+
/// Helper to drive the player into an actively-playing state.
|
|
975
892
|
@MainActor
|
|
976
|
-
private func startPlaying(_ c:
|
|
893
|
+
private func startPlaying(_ c: Player, _ eh: MockPlayerEngine) {
|
|
977
894
|
loadTrack(c)
|
|
978
895
|
eh.hasLoadedAsset = true
|
|
979
|
-
|
|
896
|
+
eh.hasItem = true
|
|
897
|
+
c.setPlayWhenReady(true)
|
|
980
898
|
c.transition(.avPlayerPlaying)
|
|
981
899
|
}
|
|
982
900
|
|
|
983
|
-
@Suite("
|
|
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.
|
|
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.
|
|
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
|
-
///
|
|
1136
|
-
/// shuffle order
|
|
1137
|
-
|
|
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("
|
|
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
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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.
|
|
1248
|
+
let task = errorHandler.pendingTask
|
|
1199
1249
|
#expect(task != nil)
|
|
1200
1250
|
|
|
1201
|
-
|
|
1202
|
-
#expect(errorHandler.
|
|
1251
|
+
player.stop()
|
|
1252
|
+
#expect(errorHandler.pendingTask == nil)
|
|
1203
1253
|
|
|
1204
1254
|
await task?.value
|
|
1205
|
-
#expect(
|
|
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("
|
|
1262
|
+
@Suite("Player - audio session release")
|
|
1213
1263
|
struct SessionReleaseTests {
|
|
1214
1264
|
@Test @MainActor
|
|
1215
1265
|
func deliberatePause_requestsRelease() {
|