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
@@ -1,549 +0,0 @@
1
- import AVFoundation
2
- import Foundation
3
- import Testing
4
-
5
- @testable import AudioBrowserTestable
6
-
7
- // MARK: - Mock RetryHandler
8
-
9
- @MainActor
10
- private final class MockRetryHandler: RetryHandling {
11
- var isEnabled = true
12
- var hasPlayed = false
13
- var retryableErrors: Set<Int> = []
14
- var attemptRetryResult = false
15
- var attemptRetryCallCount = 0
16
- var lastStartFromCurrentTime: Bool?
17
- var lastHTTPStatusCode: Int??
18
- var resetCallCount = 0
19
-
20
- func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
21
- lastHTTPStatusCode = .some(httpStatusCode)
22
- guard let error else { return false }
23
- let code = (error as NSError).code
24
- return retryableErrors.contains(code)
25
- }
26
-
27
- var attemptRetryDelayNs: UInt64 = 0
28
-
29
- func attemptRetry(startFromCurrentTime: Bool) async -> Bool {
30
- attemptRetryCallCount += 1
31
- lastStartFromCurrentTime = startFromCurrentTime
32
- if attemptRetryDelayNs > 0 {
33
- try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
34
- }
35
- return attemptRetryResult
36
- }
37
-
38
- func reset() {
39
- resetCallCount += 1
40
- }
41
- }
42
-
43
- // MARK: - Error Classification
44
-
45
- @Suite("PlaybackErrorHandler.classify")
46
- @MainActor
47
- struct ClassifyTests {
48
- @Test func internetError_returnsNotConnectedToInternet() {
49
- let error = URLError(.notConnectedToInternet)
50
- let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
51
- #expect(result == .notConnectedToInternet)
52
- }
53
-
54
- /// `.badURL` rather than `.timedOut`: timeouts now classify as
55
- /// `.hostUnreachable`, so they no longer exercise the fallback path. Both
56
- /// fallbacks are asserted to pin that `fallback` is plumbed, not hardcoded.
57
- @Test func unclassifiableError_returnsFallback() {
58
- #expect(
59
- PlaybackErrorHandler.classify(error: URLError(.badURL), fallback: .playbackFailed)
60
- == .playbackFailed,
61
- )
62
- #expect(
63
- PlaybackErrorHandler.classify(error: URLError(.badURL), fallback: .failedToLoadKeyValue)
64
- == .failedToLoadKeyValue,
65
- )
66
- }
67
-
68
- @Test(arguments: [
69
- URLError.Code.timedOut,
70
- .cannotFindHost,
71
- // A TLS failure counts as unreachable: we never got a usable connection.
72
- .secureConnectionFailed,
73
- ])
74
- func transportError_returnsHostUnreachable(code: URLError.Code) {
75
- let result = PlaybackErrorHandler.classify(error: URLError(code), fallback: .playbackFailed)
76
- #expect(result == .hostUnreachable)
77
- }
78
-
79
- /// AVFoundation hands back plain `NSError`s rather than bridged `URLError`s.
80
- /// `as?` covers this, but only for `NSURLErrorDomain` — worth pinning, since
81
- /// losing it would silently downgrade every real timeout to the fallback.
82
- @Test func unbridgedUrlDomainError_isClassified() {
83
- let error = NSError(domain: NSURLErrorDomain, code: URLError.Code.cannotFindHost.rawValue)
84
- let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
85
- #expect(result == .hostUnreachable)
86
- }
87
-
88
- @Test func httpStatus_isSurfaced() {
89
- let result = PlaybackErrorHandler.classify(
90
- error: nil,
91
- fallback: .failedToLoadKeyValue,
92
- httpStatusCode: 404,
93
- )
94
- #expect(result == .httpStatus(404))
95
- }
96
-
97
- /// Also covers the nil-error path: with no error and no usable status, the
98
- /// context fallback is all that is left.
99
- @Test func successStatus_returnsFallback() {
100
- #expect(
101
- PlaybackErrorHandler.classify(
102
- error: nil,
103
- fallback: .failedToLoadKeyValue,
104
- httpStatusCode: 200,
105
- ) == .failedToLoadKeyValue,
106
- )
107
- #expect(
108
- PlaybackErrorHandler.classify(error: nil, fallback: .playbackFailed) == .playbackFailed,
109
- )
110
- }
111
-
112
- /// A transport failure means no response arrived, so a status left over in
113
- /// the item's error log must not outrank it.
114
- @Test func transportError_outranksHttpStatus() {
115
- let result = PlaybackErrorHandler.classify(
116
- error: URLError(.notConnectedToInternet),
117
- fallback: .playbackFailed,
118
- httpStatusCode: 500,
119
- )
120
- #expect(result == .notConnectedToInternet)
121
- }
122
-
123
- /// A `URLError` we can't classify must fall *through* to the status check,
124
- /// not short-circuit to the fallback.
125
- @Test func unclassifiableTransportError_stillUsesHttpStatus() {
126
- let result = PlaybackErrorHandler.classify(
127
- error: URLError(.badURL),
128
- fallback: .playbackFailed,
129
- httpStatusCode: 500,
130
- )
131
- #expect(result == .httpStatus(500))
132
- }
133
-
134
- /// The server's own answer is better evidence than "the decoder choked",
135
- /// which is a downstream symptom of being handed an error page.
136
- @Test func httpStatus_outranksUnplayableMedia() {
137
- let error = NSError(
138
- domain: AVFoundationErrorDomain,
139
- code: AVError.fileFormatNotRecognized.rawValue,
140
- )
141
- let result = PlaybackErrorHandler.classify(
142
- error: error,
143
- fallback: .playbackFailed,
144
- httpStatusCode: 404,
145
- )
146
- #expect(result == .httpStatus(404))
147
- }
148
-
149
- @Test func unplayableMedia_returnsTrackWasUnplayable() {
150
- let error = NSError(
151
- domain: AVFoundationErrorDomain,
152
- code: AVError.fileFormatNotRecognized.rawValue,
153
- )
154
- let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
155
- #expect(result == .trackWasUnplayable)
156
- }
157
-
158
- /// Being in `AVFoundationErrorDomain` is not enough — `AVError.unknown` is
159
- /// in-domain but tells us nothing.
160
- @Test func unrelatedAVError_returnsFallback() {
161
- let error = NSError(domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue)
162
- let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
163
- #expect(result == .playbackFailed)
164
- }
165
- }
166
-
167
- // MARK: - Error Log Status Extraction
168
-
169
- /// The sole source of every `.httpStatus` classification in production.
170
- @Suite("PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes:)")
171
- @MainActor
172
- struct ErrorLogStatusTests {
173
- /// `errorStatusCode` carries negative OSStatus values in the same field.
174
- @Test func skipsNonHttpStatusCodes() {
175
- #expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [-12939, -1008]) == nil)
176
- #expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [-12939, 404]) == 404)
177
- }
178
-
179
- /// A live stream reconnects; older entries describe attempts that are no
180
- /// longer why we are failing.
181
- @Test func prefersTheNewestInRangeStatus() {
182
- #expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [500, 404]) == 404)
183
- #expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [404, 500, -12939]) == 500)
184
- }
185
-
186
- @Test func nilWhenNothingQualifies() {
187
- #expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: []) == nil)
188
- }
189
- }
190
-
191
- // MARK: - Cross-platform Kind Mapping
192
-
193
- /// Only the HTTP table is tested: the `kind` switch is exhaustive with no
194
- /// `default`, so the compiler already forces every new case to be classified
195
- /// deliberately, and asserting the other arms would just retype the switch.
196
- /// The status ranges are different — `404, 410` must precede `400...499`.
197
- @Suite("PlaybackError.kind")
198
- @MainActor
199
- struct PlaybackErrorKindTests {
200
- @Test func httpStatus_mapsToKind() {
201
- // Not parameterized: the generated Nitro enum is not Sendable, so it
202
- // cannot cross a `@Test(arguments:)` isolation boundary.
203
- let expectations: [(Int, PlaybackErrorKind)] = [
204
- // Ordering: these must not be swallowed by the 400...499 arm.
205
- (404, .notFound),
206
- (410, .notFound),
207
- // Range edges.
208
- (400, .rejected),
209
- (499, .rejected),
210
- (500, .serverError),
211
- (599, .serverError),
212
- (302, .unknown),
213
- (600, .unknown),
214
- ]
215
- for (status, expected) in expectations {
216
- #expect(
217
- TrackPlayerError.PlaybackError.httpStatus(status).kind == expected,
218
- "HTTP \(status)",
219
- )
220
- }
221
- }
222
- }
223
-
224
- // `PlaybackErrorContext`'s two properties are asserted through `handleError`
225
- // below — `lastStartFromCurrentTime` and the surfaced fallback — rather than
226
- // read off the enum directly, so the tests exercise the path a caller uses.
227
-
228
- // MARK: - handleError
229
-
230
- @Suite("handleError")
231
- @MainActor
232
- struct HandleErrorTests {
233
- @Test func nonRetryableError_surfacesImmediately() {
234
- let mock = MockRetryHandler()
235
- let handler = PlaybackErrorHandler(retryHandler: mock)
236
- var surfacedError: TrackPlayerError.PlaybackError?
237
- handler.onError = { surfacedError = $0 }
238
-
239
- let error = URLError(.badURL)
240
- handler.handleError(error, context: .playback)
241
-
242
- #expect(surfacedError == .playbackFailed)
243
- #expect(mock.attemptRetryCallCount == 0)
244
- }
245
-
246
- @Test func nonRetryableInternetError_classifiesCorrectly() {
247
- let mock = MockRetryHandler()
248
- // Not in retryableErrors set, so isRetryable returns false
249
- let handler = PlaybackErrorHandler(retryHandler: mock)
250
- var surfacedError: TrackPlayerError.PlaybackError?
251
- handler.onError = { surfacedError = $0 }
252
-
253
- let error = URLError(.notConnectedToInternet)
254
- handler.handleError(error, context: .mediaLoad)
255
-
256
- // Even though not retryable, classify should detect the internet error
257
- #expect(surfacedError == .notConnectedToInternet)
258
- }
259
-
260
- /// Also pins that a completed retry task clears its own handle:
261
- /// `evaluateSessionRelease` gates on `pendingRetryTask == nil`, so a stale
262
- /// completed task blocked audio-session release for the rest of the track.
263
- @Test func retryableError_triggersRetry() async {
264
- let mock = MockRetryHandler()
265
- mock.retryableErrors = [URLError.Code.timedOut.rawValue]
266
- mock.attemptRetryResult = true
267
- let handler = PlaybackErrorHandler(retryHandler: mock)
268
- var surfaced: [TrackPlayerError.PlaybackError] = []
269
- handler.onError = { surfaced.append($0) }
270
-
271
- let error = URLError(.timedOut)
272
- handler.handleError(error, context: .playback)
273
- #expect(handler.pendingRetryTask != nil)
274
-
275
- // Wait for the retry task to complete
276
- await handler.pendingRetryTask?.value
277
-
278
- #expect(mock.attemptRetryCallCount == 1)
279
- #expect(mock.lastStartFromCurrentTime == true)
280
- // Retry succeeded, no error surfaced
281
- #expect(surfaced.isEmpty)
282
- #expect(handler.pendingRetryTask == nil)
283
- }
284
-
285
- /// A cancelled retry must stay silent: track change / stop cancel the task,
286
- /// but its tail still runs — attemptRetry returns false (wait cancelled) and
287
- /// the unguarded body surfaced .errorOccurred over the new track / the
288
- /// deliberate stop.
289
- @Test func cancelledRetry_staysSilent() async throws {
290
- let mock = MockRetryHandler()
291
- mock.retryableErrors = [URLError.Code.timedOut.rawValue]
292
- mock.attemptRetryResult = false
293
- mock.attemptRetryDelayNs = 50_000_000
294
- let handler = PlaybackErrorHandler(retryHandler: mock)
295
- var surfaced: [TrackPlayerError.PlaybackError] = []
296
- handler.onError = { surfaced.append($0) }
297
-
298
- handler.handleError(URLError(.timedOut), context: .playback)
299
- let task = handler.pendingRetryTask
300
- handler.cancelPendingRetry()
301
- #expect(handler.pendingRetryTask == nil)
302
- await task?.value
303
-
304
- #expect(surfaced.isEmpty)
305
- }
306
-
307
- /// A second failure while a retry is in flight cancels the first. The
308
- /// superseded task's tail must not surface an error, and must not null out
309
- /// the handle belonging to the retry that replaced it.
310
- @Test func supersedingError_cancelsTheInFlightRetry() async {
311
- let mock = MockRetryHandler()
312
- mock.retryableErrors = [URLError.Code.timedOut.rawValue]
313
- mock.attemptRetryResult = true
314
- mock.attemptRetryDelayNs = 20_000_000
315
- let handler = PlaybackErrorHandler(retryHandler: mock)
316
- var surfaced: [TrackPlayerError.PlaybackError] = []
317
- handler.onError = { surfaced.append($0) }
318
-
319
- handler.handleError(URLError(.timedOut), context: .playback)
320
- let first = handler.pendingRetryTask
321
- handler.handleError(URLError(.timedOut), context: .playback)
322
- let second = handler.pendingRetryTask
323
-
324
- await first?.value
325
- await second?.value
326
-
327
- #expect(mock.attemptRetryCallCount == 2)
328
- #expect(surfaced.isEmpty)
329
- }
330
-
331
- /// The advisory report must precede the retry outcome: it exists so UIs can
332
- /// show the cause while the backoff runs, and it must carry the same
333
- /// classification the terminal path would produce.
334
- @Test func retryableError_reportsRetryingErrorBeforeTheRetryResolves() async {
335
- let mock = MockRetryHandler()
336
- mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
337
- mock.attemptRetryResult = true
338
- let handler = PlaybackErrorHandler(retryHandler: mock)
339
- var retrying: [TrackPlayerError.PlaybackError] = []
340
- var surfaced: [TrackPlayerError.PlaybackError] = []
341
- handler.onRetryingError = { retrying.append($0) }
342
- handler.onError = { surfaced.append($0) }
343
-
344
- handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
345
-
346
- // Reported synchronously, before the retry task has run at all.
347
- #expect(retrying == [.hostUnreachable])
348
- #expect(mock.attemptRetryCallCount == 0)
349
-
350
- await handler.pendingRetryTask?.value
351
- // A successful retry surfaces nothing further.
352
- #expect(surfaced.isEmpty)
353
- }
354
-
355
- /// Exhaustion after an advisory report still surfaces the terminal error —
356
- /// the advisory does not consume it.
357
- @Test func retryExhausted_reportsRetryingThenTerminal() async {
358
- let mock = MockRetryHandler()
359
- mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
360
- mock.attemptRetryResult = false
361
- let handler = PlaybackErrorHandler(retryHandler: mock)
362
- var retrying: [TrackPlayerError.PlaybackError] = []
363
- var surfaced: [TrackPlayerError.PlaybackError] = []
364
- handler.onRetryingError = { retrying.append($0) }
365
- handler.onError = { surfaced.append($0) }
366
-
367
- handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
368
- await handler.pendingRetryTask?.value
369
-
370
- #expect(retrying == [.hostUnreachable])
371
- #expect(surfaced == [.hostUnreachable])
372
- }
373
-
374
- /// With the policy disabled every error is terminal: no advisory, no retry
375
- /// task — the error surfaces synchronously even for a retryable class.
376
- @Test func retryDisabled_skipsTheRetryingReport() {
377
- let mock = MockRetryHandler()
378
- mock.isEnabled = false
379
- mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
380
- let handler = PlaybackErrorHandler(retryHandler: mock)
381
- var retrying: [TrackPlayerError.PlaybackError] = []
382
- var surfacedError: TrackPlayerError.PlaybackError?
383
- handler.onRetryingError = { retrying.append($0) }
384
- handler.onError = { surfacedError = $0 }
385
-
386
- handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
387
-
388
- #expect(retrying.isEmpty)
389
- #expect(handler.pendingRetryTask == nil)
390
- #expect(surfacedError == .hostUnreachable)
391
- #expect(mock.attemptRetryCallCount == 0)
392
- }
393
-
394
- /// The status reaches `classify` on the non-retryable path. Both call sites
395
- /// forward it separately; dropping either one is invisible otherwise.
396
- @Test func nonRetryable_forwardsHttpStatusCode() {
397
- let mock = MockRetryHandler()
398
- let handler = PlaybackErrorHandler(retryHandler: mock)
399
- var surfacedError: TrackPlayerError.PlaybackError?
400
- handler.onError = { surfacedError = $0 }
401
-
402
- handler.handleError(nil, context: .playback, httpStatusCode: 404)
403
-
404
- #expect(surfacedError == .httpStatus(404))
405
- }
406
-
407
- /// The same, on the retry-exhausted path.
408
- @Test func retryExhausted_forwardsHttpStatusCode() async {
409
- let mock = MockRetryHandler()
410
- mock.retryableErrors = [URLError.Code.badURL.rawValue]
411
- mock.attemptRetryResult = false
412
- let handler = PlaybackErrorHandler(retryHandler: mock)
413
- var surfacedError: TrackPlayerError.PlaybackError?
414
- handler.onError = { surfacedError = $0 }
415
-
416
- handler.handleError(URLError(.badURL), context: .playback, httpStatusCode: 503)
417
- await handler.pendingRetryTask?.value
418
-
419
- #expect(surfacedError == .httpStatus(503))
420
- }
421
-
422
- /// `.badURL` keeps this focused on the retry-exhaustion path: it is
423
- /// unclassifiable, so the surfaced error is the context fallback and not a
424
- /// classification the error itself carried.
425
- @Test func retryExhausted_surfacesClassifiedError() async {
426
- let mock = MockRetryHandler()
427
- mock.retryableErrors = [URLError.Code.badURL.rawValue]
428
- mock.attemptRetryResult = false
429
- let handler = PlaybackErrorHandler(retryHandler: mock)
430
- var surfacedError: TrackPlayerError.PlaybackError?
431
- handler.onError = { surfacedError = $0 }
432
-
433
- let error = URLError(.badURL)
434
- handler.handleError(error, context: .mediaLoad)
435
-
436
- await handler.pendingRetryTask?.value
437
-
438
- #expect(mock.attemptRetryCallCount == 1)
439
- #expect(mock.lastStartFromCurrentTime == false)
440
- #expect(surfacedError == .failedToLoadKeyValue)
441
- }
442
-
443
- @Test func retryExhausted_internetError_classifiesCorrectly() async {
444
- let mock = MockRetryHandler()
445
- mock.retryableErrors = [URLError.Code.notConnectedToInternet.rawValue]
446
- mock.attemptRetryResult = false
447
- let handler = PlaybackErrorHandler(retryHandler: mock)
448
- var surfacedError: TrackPlayerError.PlaybackError?
449
- handler.onError = { surfacedError = $0 }
450
-
451
- let error = URLError(.notConnectedToInternet)
452
- handler.handleError(error, context: .mediaLoad)
453
-
454
- await handler.pendingRetryTask?.value
455
-
456
- // This is the bug fix: previously mediaLoad non-retryable path
457
- // would always return .failedToLoadKeyValue
458
- #expect(surfacedError == .notConnectedToInternet)
459
- }
460
- }
461
-
462
- // `cancelPendingRetry` is asserted through `cancelledRetry_staysSilent`, which
463
- // exercises the cancel *and* the silence it has to buy.
464
-
465
- // MARK: - resetRetry
466
-
467
- @Suite("resetRetry")
468
- @MainActor
469
- struct ErrorHandlerResetTests {
470
- /// A restart from terminal error must reset budgets AND mark playback
471
- /// unproven — keeping `hasPlayed` would grant the tap a 2-minute recovery
472
- /// window at the one moment the listener is definitely watching.
473
- @Test func resetForNewLoad_clearsBudgetsAndHasPlayed() {
474
- let mock = MockRetryHandler()
475
- mock.hasPlayed = true
476
- let handler = PlaybackErrorHandler(retryHandler: mock)
477
-
478
- handler.resetForNewLoad()
479
-
480
- #expect(mock.resetCallCount == 1)
481
- #expect(mock.hasPlayed == false)
482
- }
483
-
484
- /// A retry is started first: with no pending task the `pendingRetryTask ==
485
- /// nil` assertion passes even if `resetRetry` drops its `cancelPendingRetry`.
486
- @Test func resetCancelsAndResetsHandler() async {
487
- let mock = MockRetryHandler()
488
- mock.retryableErrors = [URLError.Code.timedOut.rawValue]
489
- mock.attemptRetryResult = true
490
- mock.attemptRetryDelayNs = 20_000_000
491
- let handler = PlaybackErrorHandler(retryHandler: mock)
492
-
493
- handler.handleError(URLError(.timedOut), context: .playback)
494
- let task = handler.pendingRetryTask
495
- #expect(task != nil)
496
-
497
- handler.resetRetry()
498
-
499
- #expect(handler.pendingRetryTask == nil)
500
- #expect(mock.resetCallCount == 1)
501
- await task?.value
502
- }
503
- }
504
-
505
- // MARK: - Nitro Payload
506
-
507
- @Suite("PlaybackError.toNitroError")
508
- @MainActor
509
- struct PlaybackErrorNitroCodeTests {
510
- @Test func httpStatus_producesTheFullPayload() {
511
- let payload = TrackPlayerError.PlaybackError.httpStatus(404).toNitroError()
512
- #expect(payload.kind == .notFound)
513
- #expect(payload.code == "http-status")
514
- #expect(payload.message == "Server responded with HTTP 404")
515
- #expect(payload.statusCode == 404)
516
- }
517
-
518
- @Test func nonHttpError_carriesNoStatusCode() {
519
- #expect(TrackPlayerError.PlaybackError.playbackStalled.toNitroError().statusCode == nil)
520
- }
521
-
522
- /// `retrying` is nil — not false — on a terminal payload, so JS consumers
523
- /// see the field only while it means something.
524
- @Test func retryingFlag_isNilUnlessSet() {
525
- #expect(TrackPlayerError.PlaybackError.hostUnreachable.toNitroError().retrying == nil)
526
- #expect(
527
- TrackPlayerError.PlaybackError.hostUnreachable.toNitroError(retrying: true).retrying == true,
528
- )
529
- }
530
-
531
- /// `code` is an untyped wire contract: nothing on either side of the bridge
532
- /// catches a typo, and telemetry groups issues by it. Worth an exhaustive
533
- /// table even though `kind` is what the UI branches on.
534
- @Test func codes_areStable() {
535
- let expectations: [(TrackPlayerError.PlaybackError, String)] = [
536
- (.failedToLoadKeyValue, "failed-to-load"),
537
- (.invalidSourceUrl("nil"), "invalid-source-url"),
538
- (.notConnectedToInternet, "not-connected-to-internet"),
539
- (.hostUnreachable, "host-unreachable"),
540
- (.httpStatus(500), "http-status"),
541
- (.playbackFailed, "playback-failed"),
542
- (.trackWasUnplayable, "track-unplayable"),
543
- (.playbackStalled, "playback-stalled"),
544
- ]
545
- for (error, expected) in expectations {
546
- #expect(error.toNitroError().code == expected, "\(error)")
547
- }
548
- }
549
- }
@@ -1,49 +0,0 @@
1
- import Foundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- @Suite("PlaybackStateStore", .serialized)
7
- struct PlaybackStateStoreTests {
8
- private func freshDefaults() -> UserDefaults {
9
- let suite = "PlaybackStateStoreTests"
10
- let d = UserDefaults(suiteName: suite)!
11
- d.removePersistentDomain(forName: suite)
12
- return d
13
- }
14
-
15
- private func sampleTrack() -> JsonTrack {
16
- JsonTrack(
17
- id: "abc", path: nil, title: "Test FM", subtitle: nil, artwork: nil,
18
- artist: "City, Country", albumPath: nil, album: nil, description: nil,
19
- genre: nil, duration: nil, src: "/listen/abc/channel.mp3", request: nil,
20
- style: JsonStyle(display: "grid", artworkRendering: "stencil"),
21
- disabled: false, live: true, icyCharset: "windows-1251",
22
- )
23
- }
24
-
25
- @Test func roundTrip_preservesState() {
26
- let store = PlaybackStateStore(defaults: freshDefaults())
27
- let state = PersistedPlaybackState(
28
- track: sampleTrack(), positionMs: nil, repeatMode: "off",
29
- shuffleEnabled: false, playbackSpeed: 1.0,
30
- )
31
- store.save(state)
32
- let loaded = store.load()
33
- #expect(loaded?.track.src == "/listen/abc/channel.mp3")
34
- #expect(loaded?.track.live == true)
35
- #expect(loaded?.positionMs == nil)
36
- #expect(loaded?.playbackSpeed == 1.0)
37
- }
38
-
39
- @Test func load_isNilBeforeAnySave() {
40
- #expect(PlaybackStateStore(defaults: freshDefaults()).load() == nil)
41
- }
42
-
43
- @Test func clear_removesState() {
44
- let store = PlaybackStateStore(defaults: freshDefaults())
45
- store.save(PersistedPlaybackState(track: sampleTrack(), positionMs: 5000, repeatMode: "off", shuffleEnabled: false, playbackSpeed: 1.0))
46
- store.clear()
47
- #expect(store.load() == nil)
48
- }
49
- }
@@ -1,37 +0,0 @@
1
- import AVFoundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- @MainActor
7
- @Suite("PlayerTimeObserver")
8
- struct PlayerTimeObserverTests {
9
- /// An AVPlayer retains its time observers, so one left registered outlives both this object and
10
- /// the caller's interest in it (#96).
11
- @Test func unregisteringDropsTheToken() {
12
- // Held: `avPlayer` is weak, so a temporary is gone before the next line.
13
- let player = AVPlayer()
14
- let observer = PlayerTimeObserver(onAudioDidStart: {})
15
- observer.avPlayer = player
16
-
17
- observer.registerForBoundaryTimeEvents()
18
- #expect(observer.boundaryTimeStartObserverToken != nil)
19
-
20
- observer.unregisterForBoundaryTimeEvents()
21
- #expect(observer.boundaryTimeStartObserverToken == nil)
22
- }
23
-
24
- /// Swapping players has to unregister from the outgoing one — it is a different AVPlayer, and
25
- /// the token cannot be removed from it afterwards.
26
- @Test func swappingPlayersUnregistersFromThePrevious() {
27
- let first = AVPlayer()
28
- let second = AVPlayer()
29
- let observer = PlayerTimeObserver(onAudioDidStart: {})
30
- observer.avPlayer = first
31
- observer.registerForBoundaryTimeEvents()
32
-
33
- observer.avPlayer = second
34
-
35
- #expect(observer.boundaryTimeStartObserverToken == nil)
36
- }
37
- }
@@ -1,57 +0,0 @@
1
- import Foundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- @Suite("PlayingStateManager")
7
- struct PlayingStateManagerTests {
8
- @MainActor
9
- private func makeManager() -> (manager: PlayingStateManager, changes: () -> [PlayingState]) {
10
- var recorded: [PlayingState] = []
11
- let manager = PlayingStateManager { recorded.append($0) }
12
- return (manager, { recorded })
13
- }
14
-
15
- @Test @MainActor
16
- func playing_whenIntentAndActiveState() {
17
- let (m, _) = makeManager()
18
- m.update(playWhenReady: true, state: .playing)
19
- #expect(m.playing == true)
20
- #expect(m.buffering == false)
21
- }
22
-
23
- @Test @MainActor
24
- func buffering_whenIntentAndLoading() {
25
- let (m, _) = makeManager()
26
- m.update(playWhenReady: true, state: .loading)
27
- #expect(m.playing == true)
28
- #expect(m.buffering == true)
29
- }
30
-
31
- @Test @MainActor
32
- func notPlaying_withoutIntent() {
33
- let (m, _) = makeManager()
34
- m.update(playWhenReady: false, state: .playing)
35
- #expect(m.playing == false)
36
- }
37
-
38
- /// Terminal states are not playable regardless of intent. .stopped was
39
- /// missing from the exclusion list — masked only by stop() clearing
40
- /// playWhenReady right after transitioning, which made the gap latent.
41
- @Test @MainActor
42
- func notPlaying_inTerminalStates_evenWithIntent() {
43
- for state in [PlaybackState.none, .stopped, .ended, .error] {
44
- let (m, _) = makeManager()
45
- m.update(playWhenReady: true, state: state)
46
- #expect(m.playing == false, "Expected not playing in \(state) despite intent")
47
- }
48
- }
49
-
50
- @Test @MainActor
51
- func onChange_firesOnlyOnActualChange() {
52
- let (m, changes) = makeManager()
53
- m.update(playWhenReady: true, state: .playing)
54
- m.update(playWhenReady: true, state: .playing)
55
- #expect(changes().count == 1)
56
- }
57
- }