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.
Files changed (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -1,9 +1,151 @@
1
1
  import Foundation
2
2
  import NitroModules
3
+ import os.log
4
+
5
+ /// Builds and runs the browser's HTTP requests: the Request-Config Layers for browse, search,
6
+ /// media and artwork, and the API fetches.
7
+ @MainActor
8
+ final class RequestPipeline {
9
+ let logger = Logger(subsystem: "com.audiobrowser", category: "RequestPipeline")
10
+ private let httpClient = HttpClient()
11
+ /// The request/browse thunks resolve once per content generation.
12
+ private let layers = GenerationMemo<ResolvedLayers>()
13
+
14
+ /// Set by `BrowserManager`; a new config resolves the layers again.
15
+ var config = BrowserConfig() {
16
+ didSet { layers.invalidate() }
17
+ }
18
+
19
+ nonisolated init() {}
20
+
21
+ /// Resolves the request and browse layers again on next use (e.g. after a locale change).
22
+ func invalidateLayers() {
23
+ layers.invalidate()
24
+ }
3
25
 
4
- // MARK: - URL Resolution
26
+ struct ResolvedLayers {
27
+ let request: TransformableRequestConfig?
28
+ let browse: TransformableRequestConfig?
29
+ }
30
+
31
+ /// The request and browse layers of the current config: each resolver's result, or the
32
+ /// static layer when it has none. The resolvers run one after the other, request first, as on
33
+ /// Android and web, and a config change or `clearContentCache` while they run resolves them
34
+ /// again.
35
+ func resolvedLayers() async throws -> ResolvedLayers {
36
+ try await layers.value { [weak self] in
37
+ guard let self else { throw CancellationError() }
38
+ // Read on every run: a retry after a config change must resolve the new config's layers.
39
+ let config = self.config
40
+ // The resolver is Promise-only (the TS layer wraps a sync-or-async thunk in
41
+ // Promise.resolve), so its result is the JS promise behind the bridge promise.
42
+ let request = try await config.requestResolver?().await().await() ?? config.request
43
+ let browse = try await config.browseResolver?().await().await() ?? config.browse
44
+ return ResolvedLayers(request: request, browse: browse)
45
+ }
46
+ }
47
+
48
+ /// Applies one Request-Config Layer (request / browse / route) onto `base`: its transforms
49
+ /// (async, then sync) when it has any, else its static fields (`RequestLayer.apply`).
50
+ func applyLayer(
51
+ _ layer: TransformableRequestConfig?,
52
+ to base: RequestConfig,
53
+ params: [String: String],
54
+ ) async throws -> RequestConfig {
55
+ guard let layer else { return base }
56
+ var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
57
+ if let transform = layer.transform {
58
+ transforms.append { try await self.awaitAsyncConfig(transform($0, params)) }
59
+ }
60
+ if let transformSync = layer.transformSync {
61
+ transforms.append { try await self.awaitSyncConfig(transformSync($0, params)) }
62
+ }
63
+ let fields = RequestConfig(
64
+ method: layer.method, path: nil, baseUrl: layer.baseUrl, headers: layer.headers,
65
+ query: layer.query, body: layer.body, contentType: layer.contentType, userAgent: layer.userAgent,
66
+ )
67
+ return try await RequestLayer.apply(fields, transforms: transforms, to: base)
68
+ }
69
+
70
+ /// Builds the HTTP request for an API-backed route by layering
71
+ /// request → kind → route configs. `initialQuery` seeds request-level query
72
+ /// params (e.g. search q/mode/…) onto the base before the route layer, so they
73
+ /// survive into `request.query` even when a layer's transform "wins
74
+ /// completely" (it is handed only the base, so the route config's own static
75
+ /// query would otherwise be dropped). `routeConfig` is nil for the implicit
76
+ /// browse default; `kind` names the per-kind layer, nil for kinds with none. The request and
77
+ /// kind layers come from one resolution; `routeConfig` from the caller's config.
78
+ func buildApiRequest(
79
+ kind: KeyPath<ResolvedLayers, TransformableRequestConfig?>?,
80
+ _ routeConfig: TransformableRequestConfig?,
81
+ path: String,
82
+ params: [String: String],
83
+ initialQuery: [String: String]? = nil,
84
+ ) async throws -> HttpClient.HttpRequest {
85
+ var merged = RequestConfig(
86
+ method: nil, path: path, baseUrl: nil, headers: nil,
87
+ query: nil, body: nil, contentType: nil, userAgent: nil,
88
+ )
89
+ let layers = try await resolvedLayers()
90
+ merged = try await applyLayer(layers.request, to: merged, params: params)
91
+ if let initialQuery, !initialQuery.isEmpty {
92
+ merged = merged.copying(query: RequestLayer.mergeDicts(merged.query, initialQuery))
93
+ }
94
+ merged = try await applyLayer(kind.flatMap { layers[keyPath: $0] }, to: merged, params: params)
95
+ merged = try await applyLayer(routeConfig, to: merged, params: params)
96
+
97
+ guard merged.baseUrl != nil else {
98
+ throw BrowserError.invalidConfiguration("No URL configured for route")
99
+ }
100
+ return HttpClient.HttpRequest(
101
+ url: RequestLayer.buildUrl(merged, defaultPath: path),
102
+ method: merged.method?.stringValue ?? "GET",
103
+ headers: merged.headers,
104
+ body: merged.body,
105
+ contentType: merged.contentType ?? HttpClient.defaultContentType,
106
+ userAgent: merged.userAgent ?? HttpClient.defaultUserAgent,
107
+ )
108
+ }
109
+
110
+ /// Fetches a page object (`{title,path,children:[…]}`) through the request → browse → route
111
+ /// layers.
112
+ func fetchPage(
113
+ _ routeConfig: TransformableRequestConfig?,
114
+ path: String,
115
+ params: [String: String],
116
+ ) async throws -> ResolvedTrack {
117
+ let request = try await buildApiRequest(
118
+ kind: \.browse, routeConfig, path: path, params: params,
119
+ )
120
+
121
+ logger.debug("Resolving content from API")
122
+ logger.debug(" path: \(path)")
123
+ logger.debug(" url: \(request.url)")
124
+
125
+ let result: JsonResolvedTrack = try await httpClient.requestJson(request, as: JsonResolvedTrack.self)
126
+ let nitroResult = result.toNitro()
127
+
128
+ logger.debug("Resolved: \(nitroResult.title)")
129
+ if let sections = nitroResult.normalizedSections {
130
+ logger.debug(" children: \(sections.reduce(0) { $0 + $1.children.count }) tracks")
131
+ }
132
+
133
+ return nitroResult
134
+ }
135
+
136
+ /// Fetches a search route's bare Track array through the request → search route layers. The
137
+ /// search params go through `initialQuery`, so they reach the URL even when a transform wins;
138
+ /// the config's static path seeds the base, since a layer's static path never applies.
139
+ func fetchSearch(_ searchConfig: TransformableRequestConfig, params: SearchParams) async throws -> [Track] {
140
+ let request = try await buildApiRequest(
141
+ kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": params.query],
142
+ initialQuery: PageResolver.searchQueryParams(params),
143
+ )
144
+ logger.debug("Searching via API: \(request.url)")
145
+ let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
146
+ return jsonTracks.map { $0.toNitro() }
147
+ }
5
148
 
6
- extension BrowserManager {
7
149
  // MARK: - Media URL Resolution
8
150
 
9
151
  /// Resolves a media URL using the configured media transform.
@@ -11,7 +153,7 @@ extension BrowserManager {
11
153
  ///
12
154
  /// Resolution layers, least- to most-specific: shared `request` layer →
13
155
  /// `media` transform / static fields → `media.resolve(track)`. The final
14
- /// `resolve` layer (if configured) is merged last via `mergeRequestConfig`,
156
+ /// `resolve` layer (if configured) is merged last,
15
157
  /// so its fields win. The `track` is only used to feed `resolve`; we do NOT
16
158
  /// auto-merge `track.request` here.
17
159
  func resolveMediaUrl(_ originalUrl: String, track: Track? = nil) async -> MediaResolvedUrl {
@@ -23,9 +165,8 @@ extension BrowserManager {
23
165
  // `media` config is present, so a relative src still gets baseUrl.
24
166
  let baseRequest: RequestConfig
25
167
  do {
26
- try await ensureLayersResolved()
27
168
  baseRequest = try await applyLayer(
28
- resolvedRequestLayer,
169
+ resolvedLayers().request,
29
170
  to: RequestConfig(
30
171
  method: nil, path: originalUrl, baseUrl: nil, headers: nil,
31
172
  query: nil, body: nil, contentType: nil, userAgent: nil,
@@ -56,8 +197,8 @@ extension BrowserManager {
56
197
  if let mediaConfig = config.media {
57
198
  merged = try await applyMediaLayer(mediaConfig, to: merged)
58
199
  }
59
- merged = applyMediaResolveLayer(base: merged, resolve: resolveLayer)
60
- let finalUrl = buildUrl(from: merged)
200
+ if let resolveLayer { merged = RequestLayer.merge(merged, resolveLayer) }
201
+ let finalUrl = RequestLayer.buildUrl(merged)
61
202
  logger.debug("Media URL resolved: \(originalUrl) -> \(finalUrl)")
62
203
  return MediaResolvedUrl(
63
204
  url: finalUrl,
@@ -70,46 +211,21 @@ extension BrowserManager {
70
211
  }
71
212
  }
72
213
 
73
- /// Media-kind Request-Config Layer application: a transform (async and/or sync)
74
- /// wins completely — async first, then sync, each result copied out of the Nitro
75
- /// bridge immediately (extractConfig, via awaitAsync/SyncConfig) — otherwise the
76
- /// media config's static fields merge over the base with `path` carried from the
77
- /// base (only a transform may change it). The same rule as `applyLayer`; matches
78
- /// the web stub and Android.
214
+ /// The media kind's Request-Config Layer over `base`: its transforms, when it has any, else
215
+ /// its static fields (`RequestLayer.apply`).
79
216
  private func applyMediaLayer(_ media: MediaRequestConfig, to base: RequestConfig) async throws -> RequestConfig {
80
- if media.transform != nil || media.transformSync != nil {
81
- var result = base
82
- if let transform = media.transform {
83
- result = try await awaitAsyncConfig(transform(result, nil))
84
- }
85
- if let transformSync = media.transformSync {
86
- result = try await awaitSyncConfig(transformSync(result, nil))
87
- }
88
- return result
217
+ var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
218
+ if let transform = media.transform {
219
+ transforms.append { try await self.awaitAsyncConfig(transform($0, nil)) }
89
220
  }
90
- let staticMerged = mergeRequestConfig(
91
- base: base,
92
- override: RequestConfig(
93
- method: media.method,
94
- path: media.path,
95
- baseUrl: media.baseUrl,
96
- headers: media.headers,
97
- query: media.query,
98
- body: media.body,
99
- contentType: media.contentType,
100
- userAgent: media.userAgent,
101
- ),
102
- )
103
- return RequestConfig(
104
- method: staticMerged.method,
105
- path: base.path,
106
- baseUrl: staticMerged.baseUrl,
107
- headers: staticMerged.headers,
108
- query: staticMerged.query,
109
- body: staticMerged.body,
110
- contentType: staticMerged.contentType,
111
- userAgent: staticMerged.userAgent,
221
+ if let transformSync = media.transformSync {
222
+ transforms.append { try await self.awaitSyncConfig(transformSync($0, nil)) }
223
+ }
224
+ let fields = RequestConfig(
225
+ method: media.method, path: nil, baseUrl: media.baseUrl, headers: media.headers,
226
+ query: media.query, body: media.body, contentType: media.contentType, userAgent: media.userAgent,
112
227
  )
228
+ return try await RequestLayer.apply(fields, transforms: transforms, to: base)
113
229
  }
114
230
 
115
231
  /// Invokes the consumer-supplied `media.resolve`/`resolveSync(track)` and merges
@@ -122,17 +238,7 @@ extension BrowserManager {
122
238
  if let resolve = media.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
123
239
  var syncResolved: RequestConfig?
124
240
  if let resolveSync = media.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
125
- return MediaResolveComposer.composeResolved(
126
- async: asyncResolved, sync: syncResolved,
127
- combine: { self.mergeRequestConfig(base: $0, override: $1) },
128
- )
129
- }
130
-
131
- /// Merges the resolve layer (most specific, override-wins) over `base`.
132
- /// A no-op when `resolve` is nil.
133
- private func applyMediaResolveLayer(base: RequestConfig, resolve: RequestConfig?) -> RequestConfig {
134
- guard let resolve else { return base }
135
- return mergeRequestConfig(base: base, override: resolve)
241
+ return RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved)
136
242
  }
137
243
 
138
244
  // MARK: - Artwork URL Resolution
@@ -176,9 +282,8 @@ extension BrowserManager {
176
282
  // Base config via the shared `request` layer (its transform runs for
177
283
  // artwork too), with track.artwork as the default path. Artwork then
178
284
  // shapes further via its own resolve / static fields / transform.
179
- try await ensureLayersResolved()
180
285
  var mergedConfig = try await applyLayer(
181
- resolvedRequestLayer,
286
+ resolvedLayers().request,
182
287
  to: RequestConfig(
183
288
  method: nil, path: track.artwork?.url, baseUrl: nil, headers: nil,
184
289
  query: nil, body: nil, contentType: nil, userAgent: nil,
@@ -200,18 +305,15 @@ extension BrowserManager {
200
305
  contentType: artworkConfig.contentType,
201
306
  userAgent: artworkConfig.userAgent,
202
307
  )
203
- mergedConfig = mergeRequestConfig(base: mergedConfig, override: artworkStaticConfig)
308
+ mergedConfig = RequestLayer.merge(mergedConfig, artworkStaticConfig)
204
309
 
205
310
  if artworkConfig.resolve != nil || artworkConfig.resolveSync != nil {
206
311
  var asyncResolved: RequestConfig?
207
312
  if let resolve = artworkConfig.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
208
313
  var syncResolved: RequestConfig?
209
314
  if let resolveSync = artworkConfig.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
210
- if let resolved = MediaResolveComposer.composeResolved(
211
- async: asyncResolved, sync: syncResolved,
212
- combine: { self.mergeRequestConfig(base: $0, override: $1) },
213
- ) {
214
- mergedConfig = mergeRequestConfig(base: mergedConfig, override: resolved)
315
+ if let resolved = RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved) {
316
+ mergedConfig = RequestLayer.merge(mergedConfig, resolved)
215
317
  } else if track.artwork == nil {
216
318
  // A resolver ran but produced nothing, and there's no artwork URL either
217
319
  // → no artwork (matches the web stub).
@@ -232,20 +334,7 @@ extension BrowserManager {
232
334
 
233
335
  if !contextQuery.isEmpty {
234
336
  logger.debug("Adding image query params: \(contextQuery)")
235
- var existingQuery = mergedConfig.query ?? [:]
236
- for (key, value) in contextQuery {
237
- existingQuery[key] = value
238
- }
239
- mergedConfig = RequestConfig(
240
- method: mergedConfig.method,
241
- path: mergedConfig.path,
242
- baseUrl: mergedConfig.baseUrl,
243
- headers: mergedConfig.headers,
244
- query: existingQuery,
245
- body: mergedConfig.body,
246
- contentType: mergedConfig.contentType,
247
- userAgent: mergedConfig.userAgent,
248
- )
337
+ mergedConfig = mergedConfig.copying(query: RequestLayer.mergeDicts(mergedConfig.query, contextQuery))
249
338
  }
250
339
  }
251
340
 
@@ -276,7 +365,7 @@ extension BrowserManager {
276
365
  return nil
277
366
  }
278
367
 
279
- let uri = buildUrl(from: mergedConfig)
368
+ let uri = RequestLayer.buildUrl(mergedConfig)
280
369
 
281
370
  // Build headers map, merging explicit headers with userAgent
282
371
  var headers = mergedConfig.headers ?? [:]
@@ -303,22 +392,6 @@ extension BrowserManager {
303
392
  }
304
393
  }
305
394
 
306
- // MARK: - URL Building
307
-
308
- private func buildUrl(from config: RequestConfig) -> String {
309
- let path = config.path ?? ""
310
- let baseUrl = config.baseUrl
311
-
312
- var url = BrowserPathHelper.buildUrl(baseUrl: baseUrl, path: path)
313
-
314
- // Add query parameters if any
315
- if let query = config.query, !query.isEmpty {
316
- url = BrowserPathHelper.appendQuery(query, to: url)
317
- }
318
-
319
- return url
320
- }
321
-
322
395
  // MARK: - Config Utilities
323
396
 
324
397
  /// Replaces the `{id}` token with the track id in a request config's path, query values, and
@@ -354,7 +427,6 @@ extension BrowserManager {
354
427
 
355
428
  /// Extracts all values from a RequestConfig into a new instance to avoid
356
429
  /// memory corruption in Nitro's Swift-C++ bridge when the Promise is deallocated.
357
- /// Internal so `applyLayer` (in BrowserManager.swift) can copy transform results.
358
430
  func extractConfig(_ config: RequestConfig) -> RequestConfig {
359
431
  RequestConfig(
360
432
  method: config.method,
@@ -367,18 +439,14 @@ extension BrowserManager {
367
439
  userAgent: config.userAgent,
368
440
  )
369
441
  }
442
+ }
370
443
 
371
- /// Merges two RequestConfig objects, with override values taking precedence.
372
- private func mergeRequestConfig(base: RequestConfig, override: RequestConfig) -> RequestConfig {
373
- RequestConfig(
374
- method: override.method ?? base.method,
375
- path: override.path ?? base.path,
376
- baseUrl: override.baseUrl ?? base.baseUrl,
377
- headers: mergeDicts(base.headers, override.headers),
378
- query: mergeDicts(base.query, override.query),
379
- body: override.body ?? base.body,
380
- contentType: override.contentType ?? base.contentType,
381
- userAgent: override.userAgent ?? base.userAgent,
382
- )
444
+ extension RequestConfig: RequestFields {}
445
+
446
+ extension RequestPipeline: MediaResolving {
447
+ /// Runs the `nowPlayingArtwork` config for every playing track, id or not: an id-less track
448
+ /// leaves an `{id}` token unfilled, so the request visibly fails rather than being skipped.
449
+ func resolveNowPlayingArtwork(track: Track, imageContext: ImageContext?) async -> ImageSource? {
450
+ await resolveArtworkUrl(track: track, perRouteConfig: config.nowPlayingArtwork, imageContext: imageContext)
383
451
  }
384
452
  }
@@ -64,6 +64,17 @@ extension CPInterfaceController {
64
64
  }
65
65
  }
66
66
 
67
+ /// Replaces `expected` with `template` while `expected` is still the top template, restoring
68
+ /// it if the push fails; a no-op once the user has moved on.
69
+ func safeReplaceTop(_ expected: CPTemplate, with template: CPTemplate, animated: Bool) {
70
+ whenStackSettled { controller in
71
+ guard controller.topTemplate === expected else { return }
72
+ controller.topReplacer.push(
73
+ template, replacingTopOf: GuardedTemplateStack(controller: controller), animated: animated,
74
+ )
75
+ }
76
+ }
77
+
67
78
  /// Pushes `template`, skipping the raise cases (already in the hierarchy,
68
79
  /// stack at the depth limit).
69
80
  fileprivate func guardedPush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
@@ -150,30 +161,6 @@ extension CPInterfaceController {
150
161
  }
151
162
  }
152
163
  }
153
-
154
- func safePresent(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
155
- presentTemplate(template, animated: animated) { presented, error in
156
- if let error {
157
- Self.stackLogger.error("presentTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
158
- }
159
- completion?(presented, error)
160
- }
161
- }
162
-
163
- /// Dismisses the presented template, skipping when nothing is presented.
164
- func safeDismiss(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
165
- guard presentedTemplate != nil else {
166
- Self.stackLogger.debug("dismiss skipped: no presented template")
167
- completion?(false, nil)
168
- return
169
- }
170
- dismissTemplate(animated: animated) { dismissed, error in
171
- if let error {
172
- Self.stackLogger.error("dismissTemplate failed: \(error.localizedDescription)")
173
- }
174
- completion?(dismissed, error)
175
- }
176
- }
177
164
  }
178
165
 
179
166
  /// The guarded pop and push a replacement is made of, so a push that still
@@ -1,108 +1,60 @@
1
1
  import Foundation
2
2
 
3
- /// Represents a resolved artwork image URL with optional headers.
3
+ /// An artwork URL and the headers to fetch it with.
4
4
  struct ArtworkResolvedImage: Equatable, Sendable {
5
5
  let uri: String
6
6
  let headers: [String: String]?
7
7
  }
8
8
 
9
- /// Describes what action to take for loading artwork.
10
- /// Produced by `CarPlayArtworkResolver`, consumed by `CarPlayImageLoader`.
9
+ /// How `CarPlayImageLoader` loads one artwork.
11
10
  enum ArtworkLoadAction: Equatable, Sendable {
12
- /// Render an SF Symbol from the artwork string at the given canvas size
11
+ /// Render the `sf:` artwork at this canvas size.
13
12
  case sfSymbol(artwork: String, width: Double, height: Double)
14
- /// Fetch an image from a URL, optionally with headers, tinting, and SVG processing
15
13
  case fetch(uri: String, headers: [String: String]?, shouldTint: Bool, isSvg: Bool)
16
- /// No artwork available
17
14
  case none
18
15
  }
19
16
 
20
- /// Determines artwork loading strategy without UIKit dependencies.
21
- ///
22
- /// Encapsulates the decision tree for how to load artwork:
23
- /// 1. SF Symbol artwork → render directly
24
- /// 2. URL resolver available → resolve, then fetch from resolved URL
25
- /// 3. Fallback → fetch directly from artwork/artworkSource URL
17
+ /// Decides how CarPlay loads a track's artwork, given what the Browser layer's
18
+ /// `resolveArtworkUrl` returned for it.
26
19
  enum CarPlayArtworkResolver {
27
- /// Resolves the artwork loading strategy for a track.
28
- ///
29
- /// - Parameters:
30
- /// - artwork: The track's `artwork` field (may be an SF Symbol string or URL)
31
- /// - artworkSourceUri: The track's `artworkSource?.uri` field
32
- /// - stencil: Whether the artwork renders as a monochrome glyph tinted to
33
- /// the surface appearance (`style.artworkRendering: 'stencil'`)
34
- /// - targetWidth: Target width in points
35
- /// - targetHeight: Target height in points
36
- /// - displayScale: The CarPlay display scale factor
37
- /// - urlResolver: Optional closure that resolves artwork URLs (wraps BrowserManager)
38
- static func resolve(
39
- artwork: String?,
40
- artworkSourceUri: String?,
41
- stencil: Bool,
42
- targetWidth: Double,
43
- targetHeight: Double,
44
- displayScale: Double,
45
- urlResolver: ((Double, Double) async -> ArtworkResolvedImage?)?,
46
- ) async -> ArtworkLoadAction {
47
- // 1. Primary SF symbol check
48
- if let artwork, artwork.hasPrefix("sf:") {
49
- return .sfSymbol(artwork: artwork, width: targetWidth, height: targetHeight)
50
- }
51
-
52
- // 2. Try URL resolution via resolver
53
- if let urlResolver {
54
- let pixelWidth = targetWidth * displayScale
55
- let pixelHeight = targetHeight * displayScale
56
- if let resolved = await urlResolver(pixelWidth, pixelHeight) {
57
- guard let resolvedUrl = URL(string: resolved.uri) else {
58
- return resolveDirectFallback(
59
- artwork: artwork,
60
- artworkSourceUri: artworkSourceUri,
61
- stencil: stencil,
62
- targetWidth: targetWidth,
63
- targetHeight: targetHeight,
64
- )
65
- }
66
- let isSvg = resolvedUrl.pathExtension.lowercased() == "svg"
67
- return .fetch(
68
- uri: resolved.uri,
69
- headers: resolved.headers,
70
- shouldTint: stencil,
71
- isSvg: isSvg,
72
- )
73
- }
74
- }
75
-
76
- // 3. Direct fallback
77
- return resolveDirectFallback(
78
- artwork: artwork,
79
- artworkSourceUri: artworkSourceUri,
80
- stencil: stencil,
81
- targetWidth: targetWidth,
82
- targetHeight: targetHeight,
83
- )
20
+ /// Whether to ask the Browser layer at all: CarPlay renders an SF Symbol itself.
21
+ static func needsResolve(artwork: String?) -> Bool {
22
+ !isSFSymbol(artwork)
84
23
  }
85
24
 
86
- private static func resolveDirectFallback(
25
+ /// An SF Symbol renders; otherwise the resolved URL is fetched, falling back to the track's
26
+ /// `artwork`, then its `artworkSource` with that source's headers. `stencil` tints the image
27
+ /// to the surface's appearance.
28
+ static func action(
87
29
  artwork: String?,
88
- artworkSourceUri: String?,
30
+ artworkSource: ArtworkResolvedImage?,
31
+ resolved: ArtworkResolvedImage?,
89
32
  stencil: Bool,
90
- targetWidth: Double,
91
- targetHeight: Double,
33
+ width: Double,
34
+ height: Double,
92
35
  ) -> ArtworkLoadAction {
93
- guard let directUri = artwork ?? artworkSourceUri else { return .none }
94
-
95
- if directUri.hasPrefix("sf:") {
96
- return .sfSymbol(artwork: directUri, width: targetWidth, height: targetHeight)
36
+ if isSFSymbol(artwork), let artwork {
37
+ return .sfSymbol(artwork: artwork, width: width, height: height)
38
+ }
39
+ if let resolved, let action = fetch(resolved, stencil: stencil) {
40
+ return action
41
+ }
42
+ let direct = artwork.map { ArtworkResolvedImage(uri: $0, headers: nil) } ?? artworkSource
43
+ guard let direct else { return .none }
44
+ if isSFSymbol(direct.uri) {
45
+ return .sfSymbol(artwork: direct.uri, width: width, height: height)
97
46
  }
47
+ return fetch(direct, stencil: stencil) ?? .none
48
+ }
98
49
 
99
- guard let directUrl = URL(string: directUri) else { return .none }
100
- let isSvg = directUrl.pathExtension.lowercased() == "svg"
50
+ private static func fetch(_ image: ArtworkResolvedImage, stencil: Bool) -> ArtworkLoadAction? {
51
+ guard let url = URL(string: image.uri) else { return nil }
101
52
  return .fetch(
102
- uri: directUri,
103
- headers: nil,
104
- shouldTint: stencil,
105
- isSvg: isSvg,
53
+ uri: image.uri, headers: image.headers, shouldTint: stencil, isSvg: url.pathExtension.lowercased() == "svg",
106
54
  )
107
55
  }
56
+
57
+ private static func isSFSymbol(_ artwork: String?) -> Bool {
58
+ artwork?.hasPrefix("sf:") == true
59
+ }
108
60
  }