react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,112 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ private struct Config: RequestFields, Equatable {
6
+ var method: String?
7
+ var path: String?
8
+ var baseUrl: String?
9
+ var headers: [String: String]?
10
+ var query: [String: String]?
11
+ var body: String?
12
+ var contentType: String?
13
+ var userAgent: String?
14
+
15
+ init(
16
+ method: String? = nil,
17
+ path: String? = nil,
18
+ baseUrl: String? = nil,
19
+ headers: [String: String]? = nil,
20
+ query: [String: String]? = nil,
21
+ body: String? = nil,
22
+ contentType: String? = nil,
23
+ userAgent: String? = nil,
24
+ ) {
25
+ self.method = method
26
+ self.path = path
27
+ self.baseUrl = baseUrl
28
+ self.headers = headers
29
+ self.query = query
30
+ self.body = body
31
+ self.contentType = contentType
32
+ self.userAgent = userAgent
33
+ }
34
+ }
35
+
36
+ @Suite("RequestLayer")
37
+ struct RequestLayerTests {
38
+ @Test func mergeLetsTheOverrideWinFieldByField() {
39
+ let base = Config(method: "GET", path: "/a", baseUrl: "https://a", userAgent: "base")
40
+ let merged = RequestLayer.merge(base, Config(path: "/b", userAgent: "over"))
41
+ #expect(merged == Config(method: "GET", path: "/b", baseUrl: "https://a", userAgent: "over"))
42
+ }
43
+
44
+ @Test func mergeCombinesHeadersAndQueryByKey() {
45
+ let base = Config(headers: ["A": "1", "B": "1"], query: ["x": "1"])
46
+ let merged = RequestLayer.merge(base, Config(headers: ["B": "2"], query: ["y": "2"]))
47
+ #expect(merged.headers == ["A": "1", "B": "2"])
48
+ #expect(merged.query == ["x": "1", "y": "2"])
49
+ }
50
+
51
+ @Test func anEmptyOverrideChangesNothing() {
52
+ let base = Config(method: "POST", path: "/a", baseUrl: "https://a", headers: ["A": "1"])
53
+ #expect(RequestLayer.merge(base, Config()) == base)
54
+ }
55
+
56
+ @Test func applyStaticKeepsTheBasePath() {
57
+ let base = Config(path: "/page", baseUrl: "https://a")
58
+ let applied = RequestLayer.applyStatic(Config(path: "/ignored", baseUrl: "https://b"), to: base)
59
+ #expect(applied == Config(path: "/page", baseUrl: "https://b"))
60
+ }
61
+
62
+ @Test func transformsWinCompletelyInOrder() async throws {
63
+ let base = Config(path: "/page", baseUrl: "https://a", headers: ["A": "1"])
64
+ let layer = Config(baseUrl: "https://static")
65
+ let applied = try await RequestLayer.apply(
66
+ layer,
67
+ transforms: [
68
+ { Config(path: $0.path, baseUrl: "https://first") },
69
+ { Config(path: $0.path, baseUrl: $0.baseUrl, userAgent: "second") },
70
+ ],
71
+ to: base,
72
+ )
73
+ #expect(applied == Config(path: "/page", baseUrl: "https://first", userAgent: "second"))
74
+ }
75
+
76
+ @Test func withoutTransformsTheStaticFieldsApply() async throws {
77
+ let base = Config(path: "/page", baseUrl: "https://a")
78
+ let applied = try await RequestLayer.apply(Config(baseUrl: "https://b"), transforms: [], to: base)
79
+ #expect(applied == Config(path: "/page", baseUrl: "https://b"))
80
+ }
81
+
82
+ @Test func aFailingTransformThrows() async {
83
+ struct Boom: Error {}
84
+ await #expect(throws: Boom.self) {
85
+ _ = try await RequestLayer.apply(Config(), transforms: [{ _ in throw Boom() }], to: Config())
86
+ }
87
+ }
88
+
89
+ @Test func mergeResolvedLetsSyncWin() {
90
+ let asyncResolved = Config(baseUrl: "https://async", userAgent: "ASYNC")
91
+ let syncResolved = Config(userAgent: "SYNC")
92
+ #expect(RequestLayer.mergeResolved(async: Config?.none, sync: nil) == nil)
93
+ #expect(RequestLayer.mergeResolved(async: asyncResolved, sync: nil) == asyncResolved)
94
+ #expect(RequestLayer.mergeResolved(async: nil, sync: syncResolved) == syncResolved)
95
+ #expect(
96
+ RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved)
97
+ == Config(baseUrl: "https://async", userAgent: "SYNC"),
98
+ )
99
+ }
100
+
101
+ @Test func buildUrlJoinsBaseUrlPathAndQuery() {
102
+ #expect(RequestLayer.buildUrl(Config(path: "/a", baseUrl: "https://host")) == "https://host/a")
103
+ #expect(RequestLayer.buildUrl(Config(baseUrl: "https://host"), defaultPath: "/d") == "https://host/d")
104
+ #expect(RequestLayer.buildUrl(Config(path: "/a", baseUrl: "https://host", query: ["q": "x"])) == "https://host/a?q=x")
105
+ }
106
+
107
+ @Test func mergeDictsIsNilSafe() {
108
+ #expect(RequestLayer.mergeDicts(nil, nil) == nil)
109
+ #expect(RequestLayer.mergeDicts(["a": "1"], nil) == ["a": "1"])
110
+ #expect(RequestLayer.mergeDicts(nil, ["b": "2"]) == ["b": "2"])
111
+ }
112
+ }
@@ -0,0 +1,270 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+
5
+ @testable import AudioBrowserTestable
6
+
7
+ private final class MockNetworkStatus: NetworkStatusProviding, @unchecked Sendable {
8
+ var isOnline: Bool
9
+ init(isOnline: Bool) { self.isOnline = isOnline }
10
+ }
11
+
12
+ /// The two duration budgets: a never-played load gives up fast while online; a
13
+ /// load that produced audio — or a device that is offline — keeps the full
14
+ /// recovery budget. Timing-based: budgets are configured in single-digit
15
+ /// milliseconds so one executed backoff (~1s) exhausts them.
16
+ @Suite("RetryLoop budgets")
17
+ @MainActor
18
+ struct RetryLoopBudgetTests {
19
+ /// Returns the mock too — `networkMonitor` is weak, the test must keep it alive.
20
+ private func makeManager(
21
+ firstConnectMs: Double,
22
+ maxMs: Double = 120_000,
23
+ online: Bool,
24
+ ) -> (RetryLoop, MockNetworkStatus) {
25
+ let manager = RetryLoop()
26
+ manager.updatePolicy(from: .second(RetryConfig(
27
+ maxRetries: nil,
28
+ maxRetryDurationMs: maxMs,
29
+ firstConnectMaxRetryDurationMs: firstConnectMs,
30
+ )))
31
+ let network = MockNetworkStatus(isOnline: online)
32
+ manager.networkMonitor = network
33
+ return (manager, network)
34
+ }
35
+
36
+ /// A dead-on-arrival stream: the first attempt runs, and the budget refuses
37
+ /// a later one. Budgets are checked before AND after each backoff wait, so
38
+ /// the test budget must exceed the first backoff (~1s) for attempt one to
39
+ /// execute, and be exceeded by the cumulative ~2.5s before attempt two ends.
40
+ /// 1800ms sits ~800ms clear of the first backoff (sleep overshoot on a loaded
41
+ /// machine only widens the passing margins, never shrinks them into failure).
42
+ @Test func neverPlayedLoad_givesUpAfterFirstConnectBudget() async {
43
+ let (manager, network) = makeManager(firstConnectMs: 1800, online: true)
44
+ _ = network
45
+
46
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
47
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == false)
48
+ }
49
+
50
+ /// The same failure timeline on a load that has produced audio keeps
51
+ /// retrying: only the recovery budget applies.
52
+ @Test func playedLoad_ignoresTheFirstConnectBudget() async {
53
+ let (manager, network) = makeManager(firstConnectMs: 500, online: true)
54
+ _ = network
55
+ manager.hasPlayed = { true }
56
+
57
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
58
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
59
+ }
60
+
61
+ /// Offline failures must not burn the first-connect budget (a station tapped
62
+ /// in a tunnel parks for connectivity); the budget's clock starts at the
63
+ /// first failure observed online, so a dead station still gets a fast
64
+ /// verdict after restoration.
65
+ @Test func offlineTime_doesNotBurnTheFirstConnectBudget() async {
66
+ // 4s: well above the ~2.3s backoff of the first online attempt (the ladder
67
+ // kept climbing while offline), well below the ~5.6s cumulative after it.
68
+ let (manager, network) = makeManager(firstConnectMs: 4000, online: false)
69
+
70
+ // Offline: the short budget never engages, attempts keep running.
71
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
72
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
73
+
74
+ // Connectivity returns: the short clock starts now — one more attempt
75
+ // runs, then the budget is spent.
76
+ network.isOnline = true
77
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
78
+ #expect(await manager.attemptRetry(startFromCurrentTime: false) == false)
79
+ }
80
+ }
81
+
82
+ // MARK: - handleError
83
+
84
+ @Suite("RetryLoop.handleError")
85
+ @MainActor
86
+ struct RetryLoopHandleErrorTests {
87
+ /// What the loop reported and did, in order.
88
+ final class Record {
89
+ var retrying: [TrackPlayerError.PlaybackError] = []
90
+ var errors: [TrackPlayerError.PlaybackError] = []
91
+ var reloads: [Bool] = []
92
+ }
93
+
94
+ private func makeLoop(retries: Bool = true, delay: TimeInterval = 0) -> (RetryLoop, Record) {
95
+ let loop = makeRetryLoop(retries: retries, delay: delay)
96
+ let record = Record()
97
+ loop.onRetryingError = { record.retrying.append($0) }
98
+ loop.onError = { record.errors.append($0) }
99
+ loop.onRetry = { record.reloads.append($0) }
100
+ return (loop, record)
101
+ }
102
+
103
+ @Test func aNonRetryableErrorSurfacesAtOnce() {
104
+ let (loop, record) = makeLoop()
105
+ loop.handleError(URLError(.badURL), context: .playback)
106
+ #expect(record.errors == [.playbackFailed])
107
+ #expect(record.retrying.isEmpty)
108
+ #expect(loop.pendingTask == nil)
109
+ }
110
+
111
+ /// A disabled policy makes every error terminal: no advisory, no retry task, even for a
112
+ /// retryable failure, which is still classified.
113
+ @Test func aDisabledPolicySurfacesAtOnce() {
114
+ let (loop, record) = makeLoop()
115
+ loop.updatePolicy(from: nil)
116
+ loop.handleError(URLError(.notConnectedToInternet), context: .mediaLoad)
117
+ #expect(record.errors == [.notConnectedToInternet])
118
+ #expect(record.retrying.isEmpty)
119
+ #expect(loop.pendingTask == nil)
120
+ }
121
+
122
+ /// Also pins that a finished retry clears its handle: session release waits on it.
123
+ @Test func aRetryableErrorReloadsFromTheContextsPosition() async {
124
+ let (loop, record) = makeLoop()
125
+ loop.handleError(URLError(.timedOut), context: .playback)
126
+ #expect(loop.pendingTask != nil)
127
+ await loop.pendingTask?.value
128
+ loop.handleError(URLError(.timedOut), context: .mediaLoad)
129
+ await loop.pendingTask?.value
130
+
131
+ #expect(record.reloads == [true, false])
132
+ #expect(record.errors.isEmpty)
133
+ #expect(loop.pendingTask == nil)
134
+ #expect(loop.attempts == 2)
135
+ }
136
+
137
+ /// The advisory goes out before the backoff, carrying the classification a terminal report
138
+ /// would.
139
+ @Test func theAdvisoryPrecedesTheRetry() async {
140
+ let (loop, record) = makeLoop()
141
+ loop.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
142
+ #expect(record.retrying == [.hostUnreachable])
143
+ #expect(record.reloads.isEmpty)
144
+ await loop.pendingTask?.value
145
+ #expect(record.errors.isEmpty)
146
+ }
147
+
148
+ @Test func givingUpReportsTheAdvisoryThenTheTerminalError() async {
149
+ let (loop, record) = makeLoop(retries: false)
150
+ loop.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
151
+ await loop.pendingTask?.value
152
+ #expect(record.retrying == [.hostUnreachable])
153
+ #expect(record.errors == [.hostUnreachable])
154
+ }
155
+
156
+ /// A cancelled retry stays silent: a track change or a stop cancels it, and surfacing its
157
+ /// failure would flip the new track or the stop to .error.
158
+ @Test func aCancelledRetryStaysSilent() async {
159
+ let (loop, record) = makeLoop(delay: 0.05)
160
+ loop.handleError(URLError(.timedOut), context: .playback)
161
+ let task = loop.pendingTask
162
+ loop.cancel()
163
+ #expect(loop.pendingTask == nil)
164
+ await task?.value
165
+ #expect(record.errors.isEmpty)
166
+ #expect(record.reloads.isEmpty)
167
+ }
168
+
169
+ /// A second failure replaces the retry in flight, which neither reports nor reloads.
170
+ @Test func aNewFailureReplacesTheRetryInFlight() async {
171
+ let (loop, record) = makeLoop(delay: 0.02)
172
+ loop.handleError(URLError(.timedOut), context: .playback)
173
+ let first = loop.pendingTask
174
+ loop.handleError(URLError(.timedOut), context: .playback)
175
+ let second = loop.pendingTask
176
+ await first?.value
177
+ await second?.value
178
+ #expect(record.reloads == [true])
179
+ #expect(record.errors.isEmpty)
180
+ }
181
+
182
+ @Test func theHTTPStatusReachesTheReport() async {
183
+ let (loop, record) = makeLoop(retries: false)
184
+ loop.handleError(nil, context: .playback, httpStatusCode: 404)
185
+ loop.handleError(URLError(.badURL), context: .playback, httpStatusCode: 503)
186
+ await loop.pendingTask?.value
187
+ #expect(record.errors == [.httpStatus(404), .httpStatus(503)])
188
+ }
189
+
190
+ /// An unclassifiable failure, retried and given up on, reports the context's fallback.
191
+ @Test func givingUpOnAnOpaqueFailureReportsTheFallback() async {
192
+ let (loop, record) = makeLoop(retries: false)
193
+ loop.handleError(NSError(domain: "CoreMediaErrorDomain", code: -12939), context: .mediaLoad)
194
+ await loop.pendingTask?.value
195
+ #expect(record.errors == [.failedToLoadKeyValue])
196
+ }
197
+
198
+ @Test func resetRefillsTheBudgets() async {
199
+ let (loop, _) = makeLoop()
200
+ loop.handleError(URLError(.timedOut), context: .playback)
201
+ await loop.pendingTask?.value
202
+ #expect(loop.attempts == 1)
203
+ loop.reset()
204
+ #expect(loop.attempts == 0)
205
+ }
206
+
207
+ @Test func resetDropsTheRetryInFlightSilently() async {
208
+ let (loop, record) = makeLoop(delay: 0.02)
209
+ loop.handleError(URLError(.timedOut), context: .playback)
210
+ let task = loop.pendingTask
211
+ loop.reset()
212
+ #expect(loop.pendingTask == nil)
213
+ await task?.value
214
+ #expect(record.errors.isEmpty)
215
+ #expect(record.reloads.isEmpty)
216
+ }
217
+
218
+ /// A loop whose backoff waits until cancelled, reporting when it starts waiting.
219
+ private func makeWaitingLoop() -> (RetryLoop, Record, () -> Bool) {
220
+ var waiting = false
221
+ let loop = RetryLoop(sleep: { _ in
222
+ await MainActor.run { waiting = true }
223
+ try await Task.sleep(for: .seconds(60))
224
+ })
225
+ loop.updatePolicy(from: .first(true))
226
+ loop.shouldRetry = { true }
227
+ let record = Record()
228
+ loop.onError = { record.errors.append($0) }
229
+ loop.onRetry = { record.reloads.append($0) }
230
+ return (loop, record, { waiting })
231
+ }
232
+
233
+ @Test func aCancelDuringTheBackoffStaysSilent() async {
234
+ let (loop, record, isWaiting) = makeWaitingLoop()
235
+ loop.handleError(URLError(.timedOut), context: .playback)
236
+ let task = loop.pendingTask
237
+ while !isWaiting() {
238
+ await Task.yield()
239
+ }
240
+ loop.cancel()
241
+ await task?.value
242
+ #expect(record.errors.isEmpty)
243
+ #expect(record.reloads.isEmpty)
244
+ }
245
+
246
+ /// A cancel that lands as the backoff completes (the sleep itself returning normally) still
247
+ /// stops the reload.
248
+ @Test func aCancelAsTheBackoffEndsStopsTheReload() async {
249
+ let holder = LoopHolder()
250
+ let loop = RetryLoop(sleep: { _ in
251
+ await holder.loop?.cancel()
252
+ })
253
+ holder.loop = loop
254
+ loop.updatePolicy(from: .first(true))
255
+ loop.shouldRetry = { true }
256
+ let record = Record()
257
+ loop.onError = { record.errors.append($0) }
258
+ loop.onRetry = { record.reloads.append($0) }
259
+ loop.handleError(URLError(.timedOut), context: .playback)
260
+ let task = loop.pendingTask
261
+ await task?.value
262
+ #expect(record.reloads.isEmpty)
263
+ #expect(record.errors.isEmpty)
264
+ }
265
+ }
266
+
267
+ @MainActor
268
+ private final class LoopHolder {
269
+ weak var loop: RetryLoop?
270
+ }
@@ -1,6 +1,3 @@
1
- // This file is intentionally empty — mock types live in
2
- // Model/NitroTypeStubs.swift (part of the AudioBrowserTestable target).
3
-
4
1
  @testable import AudioBrowserTestable
5
2
  import AVFoundation
6
3
 
@@ -28,94 +25,116 @@ func makeMetadataItem(
28
25
  return item
29
26
  }
30
27
 
31
- // MARK: - Mock PlaybackEffectHandler
28
+ // MARK: - Mock PlayerEngine
32
29
 
33
30
  @MainActor
34
- final class MockPlaybackEffectHandler: PlaybackEffectHandler {
35
- // Playback control
36
- var startPlaybackCallCount = 0
37
- var pausePlaybackCallCount = 0
38
- var setTimePitchCallCount = 0
39
-
40
- func startPlayback() { startPlaybackCallCount += 1 }
41
- func pausePlayback() { pausePlaybackCallCount += 1 }
42
- func setTimePitchingAlgorithmForCurrentItem() { setTimePitchCallCount += 1 }
31
+ final class MockPlayerEngine: PlayerEngine {
32
+ var onEvent: (@MainActor (EngineEvent) -> Void)?
43
33
 
44
- // Player volume
45
- var volume: Float = 1
34
+ /// Reports `event` as the media stack would.
35
+ func emit(_ event: EngineEvent) {
36
+ onEvent?(event)
37
+ }
46
38
 
47
- // AVPlayer state queries
39
+ // The current item
40
+ var hasItem = false
41
+ var hasLoadedAsset = false
48
42
  var currentTime: Double = 0
49
43
  var duration: Double = 0
50
44
  var bufferedPosition: Double = 0
51
- var hasLoadedAsset: Bool = false
52
-
53
- // Item management
54
- var clearCurrentItemCallCount = 0
55
- var stopObservingCurrentItemCallCount = 0
56
-
57
- func clearCurrentItem() { clearCurrentItemCallCount += 1 }
58
- func stopObservingCurrentItem() { stopObservingCurrentItemCallCount += 1 }
59
-
60
- // Track loading
61
- var loadTrackCalls: [String] = []
62
- var loadTrackTracks: [Track] = []
63
- var reloadTrackCalls: [Bool] = []
64
- var unloadTrackCallCount = 0
65
- var cancelMediaLoadingCallCount = 0
45
+ var resumePosition: Double?
46
+ var liveEdge: Double?
47
+ var isPlaybackLikelyToKeepUp = false
48
+ var isAirPlayRoute = false
49
+ var volume: Float = 1
66
50
 
67
- func loadTrack(src: String, track: Track) {
68
- loadTrackCalls.append(src)
69
- loadTrackTracks.append(track)
51
+ // Loading
52
+ var resolveAndLoadCalls: [String] = []
53
+ var loadResolvedCount = 0
54
+ var cancelLoadingCount = 0
55
+ var dropAssetCount = 0
56
+ var clearItemCount = 0
57
+ var stopObservingItemCount = 0
58
+ var recreateCount = 0
59
+
60
+ func resolveAndLoad(src: String, track _: Track) { resolveAndLoadCalls.append(src) }
61
+ func loadResolved() { loadResolvedCount += 1 }
62
+ func cancelLoading() { cancelLoadingCount += 1 }
63
+ func dropAsset() { dropAssetCount += 1 }
64
+ func clearItem() { clearItemCount += 1 }
65
+ func stopObservingItem() { stopObservingItemCount += 1 }
66
+ func recreate() { recreateCount += 1 }
67
+
68
+ // Transport
69
+ var startPlaybackCallCount = 0
70
+ var pausePlaybackCallCount = 0
71
+ var setTimePitchCallCount = 0
72
+ var rates: [(rate: Float, playing: Bool)] = []
73
+ var seekCalls: [Double] = []
74
+ /// What a seek completes with; nil leaves it in flight until `completeSeeks`.
75
+ var seekResult: Bool? = true
76
+
77
+ func play() { startPlaybackCallCount += 1 }
78
+ func pause() { pausePlaybackCallCount += 1 }
79
+ func setRate(_ rate: Float, playing: Bool) { rates.append((rate, playing)) }
80
+ private var seeksInFlight: [@MainActor (Bool) -> Void] = []
81
+
82
+ func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void) {
83
+ seekCalls.append(seconds)
84
+ if let seekResult { completion(seekResult) } else { seeksInFlight.append(completion) }
85
+ }
86
+
87
+ /// Lands the seeks left in flight while `seekResult` was nil.
88
+ func completeSeeks(_ finished: Bool) {
89
+ let pending = seeksInFlight
90
+ seeksInFlight = []
91
+ for completion in pending {
92
+ completion(finished)
93
+ }
70
94
  }
71
95
 
72
- func reloadTrack(startFromCurrentTime: Bool) { reloadTrackCalls.append(startFromCurrentTime) }
73
- func unloadTrack() { unloadTrackCallCount += 1 }
74
- func cancelMediaLoading() { cancelMediaLoadingCallCount += 1 }
75
-
76
- // Seek operations
77
- var seekToStartCallCount = 0
78
- var replayCurrentTrackCallCount = 0
96
+ func applyTimePitchAlgorithm() { setTimePitchCallCount += 1 }
79
97
 
80
- func seekToStart() { seekToStartCallCount += 1 }
81
- func replayCurrentTrack() { replayCurrentTrackCallCount += 1 }
82
-
83
- // Now Playing
98
+ // Now playing and remote commands
99
+ var primeCount = 0
84
100
  var loadNowPlayingMetadataCalls: [Track] = []
85
101
  var clearNowPlayingCallCount = 0
86
- var updateNowPlayingStateCalls: [Bool] = []
87
-
88
- func loadNowPlayingMetadata(for track: Track) {
89
- loadNowPlayingMetadataCalls.append(track)
90
- }
91
-
92
- func clearNowPlaying() { clearNowPlayingCallCount += 1 }
93
- func updateNowPlayingState(playWhenReady: Bool) {
94
- updateNowPlayingStateCalls.append(playWhenReady)
95
- }
96
-
97
- // Remote commands
98
102
  var updateRemoteRepeatModeCalls: [RepeatMode] = []
99
103
  var updateRemoteShuffleModeCalls: [Bool] = []
100
-
101
- func updateRemoteRepeatMode(_ mode: RepeatMode) {
102
- updateRemoteRepeatModeCalls.append(mode)
103
- }
104
-
105
- func updateRemoteShuffleMode(_ enabled: Bool) {
106
- updateRemoteShuffleModeCalls.append(enabled)
107
- }
108
-
109
104
  var updateSkipAvailabilityCalls: [(canNext: Bool, canPrevious: Bool)] = []
105
+
106
+ func primeNowPlaying() { primeCount += 1 }
107
+ func loadNowPlayingMetadata(for track: Track) { loadNowPlayingMetadataCalls.append(track) }
108
+ func clearNowPlaying() { clearNowPlayingCallCount += 1 }
109
+ func updateRemoteRepeatMode(_ mode: RepeatMode) { updateRemoteRepeatModeCalls.append(mode) }
110
+ func updateRemoteShuffleMode(_ enabled: Bool) { updateRemoteShuffleModeCalls.append(enabled) }
110
111
  func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
111
112
  updateSkipAvailabilityCalls.append((canNext, canPrevious))
112
113
  }
113
114
  }
114
115
 
115
- // MARK: - Mock PlaybackCoordinatorCallbacks
116
+ /// A Player over `engine` whose retries reload while `retries` and otherwise give up, keeping
117
+ /// its resume snapshot out of the app's defaults.
118
+ @MainActor
119
+ func makePlayer(
120
+ engine: MockPlayerEngine = MockPlayerEngine(),
121
+ retry: RetryLoop = makeRetryLoop(),
122
+ retries: Bool = true,
123
+ sleepTimer: SleepTimerManager = SleepTimerManager(scheduler: ManualSleepTimerScheduler()),
124
+ saveInterval: Duration = .seconds(5),
125
+ ) -> Player {
126
+ let player = Player(
127
+ engine: engine, retry: retry, sleepTimerManager: sleepTimer,
128
+ persistenceDefaults: UserDefaults(suiteName: "PlayerTests")!, persistenceSaveInterval: saveInterval,
129
+ )
130
+ retry.shouldRetry = { retries }
131
+ return player
132
+ }
133
+
134
+ // MARK: - Mock PlayerCallbacks
116
135
 
117
136
  @MainActor
118
- final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
137
+ final class MockPlayerCallbacks: PlayerCallbacks {
119
138
  var playbackChanges: [Playback] = []
120
139
  var activeTrackChanges: [PlaybackActiveTrackChangedEvent] = []
121
140
  var progressUpdates: [PlaybackProgressUpdatedEvent] = []
@@ -123,18 +142,25 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
123
142
  var playingStateChanges: [PlayingState] = []
124
143
  var queueEndedEvents: [PlaybackQueueEndedEvent] = []
125
144
  var queueChanges: [[Track]] = []
145
+ /// The order in which the queue and active-track callbacks arrived.
146
+ var queueAndTrackOrder: [String] = []
126
147
  var repeatModeChanges: [RepeatModeChangedEvent] = []
127
148
  var shuffleEnabledChanges: [Bool] = []
128
149
  var errorEvents: [PlaybackErrorEvent] = []
129
150
  var playbackIntervalFiredCount = 0
130
151
  var releaseSessionCount = 0
131
152
 
153
+ /// The order playback, intent and playing-state changes arrived in.
154
+ var publishOrder: [String] = []
155
+
132
156
  func playerDidChangePlayback(_ playback: Playback) {
157
+ publishOrder.append("playback")
133
158
  playbackChanges.append(playback)
134
159
  }
135
160
 
136
161
  func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
137
162
  activeTrackChanges.append(event)
163
+ queueAndTrackOrder.append("activeTrack")
138
164
  }
139
165
 
140
166
  func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
@@ -146,10 +172,12 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
146
172
  }
147
173
 
148
174
  func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
175
+ publishOrder.append("playWhenReady")
149
176
  playWhenReadyChanges.append(playWhenReady)
150
177
  }
151
178
 
152
179
  func playerDidChangePlayingState(_ state: PlayingState) {
180
+ publishOrder.append("playingState")
153
181
  playingStateChanges.append(state)
154
182
  }
155
183
 
@@ -159,6 +187,7 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
159
187
 
160
188
  func playerDidChangeQueue(_ tracks: [Track]) {
161
189
  queueChanges.append(tracks)
190
+ queueAndTrackOrder.append("queue")
162
191
  }
163
192
 
164
193
  func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
@@ -176,53 +205,23 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
176
205
  func playerShouldReleaseSession() {
177
206
  releaseSessionCount += 1
178
207
  }
179
- }
180
-
181
- // MARK: - Mock SleepTimerHandling
182
208
 
183
- @MainActor
184
- final class MockSleepTimerHandling: SleepTimerHandling {
185
- var onComplete: (() -> Void)?
186
- var onFadeStart: ((_ duration: TimeInterval) -> Void)?
187
- var onFadeCancel: (() -> Void)?
188
- var onChanged: ((SleepTimerState) -> Void)?
189
- var trackChangedCallCount = 0
190
- var trackPlayedToEndCallCount = 0
191
- var clearCallCount = 0
192
-
193
- @discardableResult func clear() -> Bool {
194
- clearCallCount += 1
195
- return true
196
- }
197
-
198
- func onTrackChanged() { trackChangedCallCount += 1 }
199
- func onTrackPlayedToEnd() { trackPlayedToEndCallCount += 1 }
200
-
201
- // Consumer-facing surface — unused by the coordinator tests, present only to
202
- // satisfy the protocol. Add recorders alongside the tests that need them.
203
- func get() -> SleepTimerState? { nil }
204
- func set(seconds _: TimeInterval, fadeDuration _: TimeInterval?) {}
205
- func setToEndOfTrack() {}
209
+ var sleepTimerChanges: [SleepTimerState?] = []
210
+ func playerDidChangeSleepTimer(_ state: SleepTimerState?) {
211
+ sleepTimerChanges.append(state)
212
+ }
206
213
  }
207
214
 
208
- // MARK: - Mock RetryHandling
215
+ // MARK: - RetryLoop
209
216
 
217
+ /// A RetryLoop for tests: enabled, with an instant backoff unless `delay`, whose attempts reload
218
+ /// while `retries` and otherwise give up, as an exhausted budget does.
210
219
  @MainActor
211
- final class MockRetryHandling: RetryHandling {
212
- var isEnabled = true
213
- var hasPlayed = false
214
- var isRetryableResult = false
215
- var attemptRetryResult = false
216
- var attemptRetryDelayNs: UInt64 = 0
217
- var resetCallCount = 0
218
-
219
- func isRetryable(_: Error?, httpStatusCode _: Int?) -> Bool { isRetryableResult }
220
- func attemptRetry(startFromCurrentTime _: Bool) async -> Bool {
221
- if attemptRetryDelayNs > 0 {
222
- try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
223
- }
224
- return attemptRetryResult
225
- }
226
-
227
- func reset() { resetCallCount += 1 }
220
+ func makeRetryLoop(retries: Bool = true, delay: TimeInterval = 0) -> RetryLoop {
221
+ let loop = RetryLoop(sleep: { _ in
222
+ if delay > 0 { try await Task.sleep(for: .seconds(delay)) }
223
+ })
224
+ loop.updatePolicy(from: .first(true))
225
+ loop.shouldRetry = { retries }
226
+ return loop
228
227
  }
@@ -2,7 +2,7 @@
2
2
  import Testing
3
3
 
4
4
  /// `{id}` template substitution used by artwork URL resolution
5
- /// (BrowserManager+URLResolution.substituteTrackId). A track without a non-blank id must leave
5
+ /// (RequestPipeline.substituteTrackId). A track without a non-blank id must leave
6
6
  /// the token LITERALLY in place — never substitute an empty string, never skip — so the
7
7
  /// resulting request 404s with a self-describing URL (the resolver logs a warning keyed off
8
8
  /// `containsToken`). Mirrors Android's `substituteTrackId` / `configContainsIdToken`.