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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- 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
|
@@ -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
|
+
}
|