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,41 @@
1
+ import NitroModules
2
+
3
+ /// The browser's last navigation error and the display formatted for it, as JS sees them. A
4
+ /// newer report or a clear supersedes a display still being formatted.
5
+ @MainActor
6
+ final class NavigationErrorState {
7
+ private(set) var error: NavigationError? {
8
+ didSet { if oldValue != nil || error != nil { onErrorChanged(error) } }
9
+ }
10
+
11
+ private(set) var formatted: FormattedNavigationError? {
12
+ didSet { if oldValue != nil || formatted != nil { onFormattedChanged(formatted) } }
13
+ }
14
+
15
+ private var generation = 0
16
+ private let onErrorChanged: (NavigationError?) -> Void
17
+ private let onFormattedChanged: (FormattedNavigationError?) -> Void
18
+
19
+ init(
20
+ onErrorChanged: @escaping (NavigationError?) -> Void,
21
+ onFormattedChanged: @escaping (FormattedNavigationError?) -> Void,
22
+ ) {
23
+ self.onErrorChanged = onErrorChanged
24
+ self.onFormattedChanged = onFormattedChanged
25
+ }
26
+
27
+ func clear() {
28
+ generation &+= 1
29
+ error = nil
30
+ formatted = nil
31
+ }
32
+
33
+ func report(_ error: NavigationError, format: (NavigationError) async -> FormattedNavigationError) async {
34
+ generation &+= 1
35
+ let reported = generation
36
+ self.error = error
37
+ let display = await format(error)
38
+ guard reported == generation else { return }
39
+ formatted = display
40
+ }
41
+ }
@@ -0,0 +1,227 @@
1
+ import Foundation
2
+ import NitroModules
3
+ import os.log
4
+
5
+ /// Turns a path into its page: matches the route, resolves its source (callback, config or
6
+ /// static content), then normalizes and stamps the result (contextual paths, browse-time
7
+ /// artwork). Also resolves the tabs and search routes.
8
+ @MainActor
9
+ final class PageResolver {
10
+ /// Internal path used for the default/root browse source
11
+ static let defaultRoutePath = "__default__"
12
+
13
+ /// Internal path used for navigation tabs
14
+ static let tabsRoutePath = "__tabs__"
15
+
16
+ /// Internal path used for search
17
+ static let searchRoutePath = "__search__"
18
+
19
+ let logger = Logger(subsystem: "com.audiobrowser", category: "PageResolver")
20
+ private let router = SimpleRouter()
21
+ private let requests: RequestPipeline
22
+
23
+ private var config: BrowserConfig {
24
+ requests.config
25
+ }
26
+
27
+ nonisolated init(requests: RequestPipeline) {
28
+ self.requests = requests
29
+ }
30
+
31
+ /// The page at `path`, uncached and without favorites hydrated.
32
+ func resolve(_ path: String) async throws -> ResolvedTrack {
33
+ // Match an explicit route (or the '*' default). With no match, fall back to
34
+ // the implicit default: fetch the path via the request + browse config.
35
+ let routeEntry = (config.routes).flatMap { findBestRouteMatch(path: path, routes: $0) }
36
+
37
+ let resolvedTrack: ResolvedTrack = if let (entry, routeMatch) = routeEntry {
38
+ try await resolveRouteEntry(entry, path: path, params: routeMatch.params)
39
+ } else {
40
+ // Implicit default — no route config, just request → browse → fetch path.
41
+ try await requests.fetchPage(
42
+ nil, path: path, params: ["path": path],
43
+ )
44
+ }
45
+
46
+ // Normalize to the canonical sectioned shape (children sugar → one
47
+ // untitled section — ADR 0010), then validate and transform. The output
48
+ // never carries `children`.
49
+ if let sections = resolvedTrack.normalizedSections {
50
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each
51
+ // level is still attributable. Every page passes here whatever its
52
+ // source, and only on a cache miss. No-op outside debug builds.
53
+ InertStyleDiagnostic.warn(path: path, pageStyle: resolvedTrack.style, sections: sections)
54
+ let transformed = try await transformSections(
55
+ sections, parentPath: path, routeEntry: routeEntry?.0,
56
+ )
57
+ return resolvedTrack.copying(sections: transformed, children: .some(nil))
58
+ }
59
+
60
+ return resolvedTrack
61
+ }
62
+
63
+ /// The tabs: the `__tabs__` route's page, flattened. Nil when there is no such route.
64
+ func tabs() async throws -> [Track]? {
65
+ guard let tabsEntry = config.routes?.first(where: { $0.path == Self.tabsRoutePath }) else { return nil }
66
+ let resolved = try await resolveRouteEntry(tabsEntry, path: Self.tabsRoutePath, params: [:])
67
+ // Tabs are a flat list, not a page: a sectioned tabs source flattens.
68
+ return resolved.flattenedChildren ?? []
69
+ }
70
+
71
+ /// The `__search__` route's results for `params`, unhydrated.
72
+ func search(_ params: SearchParams) async throws -> [Track] {
73
+ guard let searchEntry = config.routes?.first(where: { $0.path == Self.searchRoutePath }) else {
74
+ throw BrowserError.contentNotFound(path: Self.searchRoutePath)
75
+ }
76
+ if let callback = searchEntry.searchCallback {
77
+ return try await callback(params).await().await()
78
+ }
79
+ if let searchConfig = searchEntry.searchConfig {
80
+ return try await requests.fetchSearch(searchConfig, params: params)
81
+ }
82
+ throw BrowserError.contentNotFound(path: Self.searchRoutePath)
83
+ }
84
+
85
+ /// The query params a structured search sends: `q` plus whichever filters are
86
+ /// set. Mirrors Android's `executeSearchApiRequest` and the web stub.
87
+ ///
88
+ /// Single source of truth for "what distinguishes one search from another" —
89
+ /// the request sends exactly this, and the cache keys on exactly this, so a
90
+ /// field added here can't be forgotten in one place and silently serve one
91
+ /// search's results for another.
92
+ static func searchQueryParams(_ params: SearchParams) -> [String: String] {
93
+ var query: [String: String] = ["q": params.query]
94
+ if let mode = params.mode { query["mode"] = mode.stringValue }
95
+ if params.reference == .my { query["reference"] = "my" }
96
+ if let genre = params.genre { query["genre"] = genre }
97
+ if let artist = params.artist { query["artist"] = artist }
98
+ if let album = params.album { query["album"] = album }
99
+ if let title = params.title { query["title"] = title }
100
+ if let playlist = params.playlist { query["playlist"] = playlist }
101
+ return query
102
+ }
103
+
104
+ // MARK: - Route Resolution
105
+
106
+ private func findBestRouteMatch(
107
+ path: String,
108
+ routes: [NativeRouteEntry],
109
+ ) -> (NativeRouteEntry, RouteMatch)? {
110
+ // Filter out special routes
111
+ let browseRoutes = routes.filter {
112
+ $0.path != Self.tabsRoutePath &&
113
+ $0.path != Self.searchRoutePath &&
114
+ $0.path != Self.defaultRoutePath
115
+ }
116
+
117
+ // Try to find a matching route
118
+ if let match = router.findBestMatch(path: path, routes: browseRoutes) {
119
+ return match
120
+ }
121
+
122
+ // Fall back to __default__ if present
123
+ if let defaultRoute = routes.first(where: { $0.path == Self.defaultRoutePath }) {
124
+ return (defaultRoute, RouteMatch(params: ["path": path]))
125
+ }
126
+
127
+ return nil
128
+ }
129
+
130
+ private func resolveRouteEntry(
131
+ _ entry: NativeRouteEntry,
132
+ path: String,
133
+ params: [String: String],
134
+ ) async throws -> ResolvedTrack {
135
+ // Priority: callback > config > static
136
+ if let callback = entry.browseCallback {
137
+ let callbackParam = BrowserSourceCallbackParam(path: path, routeParams: params)
138
+ // BrowserSourceCallback may return a BrowseResult synchronously or via a
139
+ // Promise. Nitro flattens (ResolvedTrack | BrowseError) | Promise<BrowseResult>
140
+ // into a 3-arm variant: sync track, sync error, or a Promise resolving to a
141
+ // BrowseResult (which is itself a ResolvedTrack | BrowseError variant).
142
+ switch try await callback(callbackParam).await() {
143
+ case let .first(resolvedTrack):
144
+ return resolvedTrack
145
+ case let .second(browseError):
146
+ throw BrowserError.callbackError(browseError.error)
147
+ case let .third(promise):
148
+ switch try await promise.await() {
149
+ case let .first(resolvedTrack):
150
+ return resolvedTrack
151
+ case let .second(browseError):
152
+ throw BrowserError.callbackError(browseError.error)
153
+ }
154
+ }
155
+ }
156
+
157
+ if let browseConfig = entry.browseConfig {
158
+ return try await requests.fetchPage(
159
+ browseConfig, path: path, params: params,
160
+ )
161
+ }
162
+
163
+ if let staticContent = entry.browseStatic {
164
+ return staticContent
165
+ }
166
+
167
+ throw BrowserError.contentNotFound(path: path)
168
+ }
169
+
170
+ // MARK: - Stamping
171
+
172
+ private func transformSections(
173
+ _ sections: [Section],
174
+ parentPath: String,
175
+ routeEntry: NativeRouteEntry?,
176
+ ) async throws -> [Section] {
177
+ // The contextual index is the flat page position — children concatenated
178
+ // in section order (ADR 0009/0010).
179
+ var flatIndex = 0
180
+ var pageTracks: [[Track]] = []
181
+ for section in sections {
182
+ var tracks: [Track] = []
183
+ for track in section.children {
184
+ let index = flatIndex
185
+ flatIndex += 1
186
+ if track.path == nil, track.src == nil {
187
+ throw BrowserError.invalidConfiguration(
188
+ "Track must have either 'path' or 'src' for stable identification: \(track.title)",
189
+ )
190
+ }
191
+ if track.src != nil, track.path == nil {
192
+ // src != nil guarantees a non-nil identity (id when non-blank, else src).
193
+ // The flat page position rides along as a duplicate-identity tie-breaker.
194
+ let contextualPath = BrowserPathHelper.build(
195
+ parentPath: parentPath, trackId: track.identity!, index: index,
196
+ )
197
+ tracks.append(track.copying(path: contextualPath))
198
+ } else {
199
+ tracks.append(track)
200
+ }
201
+ }
202
+ pageTracks.append(tracks)
203
+ }
204
+
205
+ // Every track's browse-time artwork (no size context) resolves at once.
206
+ let artworkConfig = routeEntry?.artwork ?? config.artwork
207
+ let artworkLoads = pageTracks.map { tracks in
208
+ tracks.map { track in
209
+ Task<ImageSource?, Never> { @MainActor in
210
+ await self.requests.resolveArtworkUrl(track: track, perRouteConfig: artworkConfig)
211
+ }
212
+ }
213
+ }
214
+
215
+ var transformed: [Section] = []
216
+ for sectionIndex in sections.indices {
217
+ var children: [Track] = []
218
+ for trackIndex in pageTracks[sectionIndex].indices {
219
+ let track = pageTracks[sectionIndex][trackIndex]
220
+ let artwork = await artworkLoads[sectionIndex][trackIndex].value
221
+ children.append(artwork.map { track.copying(artworkSource: $0) } ?? track)
222
+ }
223
+ transformed.append(sections[sectionIndex].copying(children: children))
224
+ }
225
+ return transformed
226
+ }
227
+ }
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// A request config's fields: the Nitro `RequestConfig` in the app, a plain struct in tests.
4
+ protocol RequestFields {
5
+ associatedtype Method
6
+ var method: Method? { get }
7
+ var path: String? { get }
8
+ var baseUrl: String? { get }
9
+ var headers: [String: String]? { get }
10
+ var query: [String: String]? { get }
11
+ var body: String? { get }
12
+ var contentType: String? { get }
13
+ var userAgent: String? { get }
14
+
15
+ init(
16
+ method: Method?,
17
+ path: String?,
18
+ baseUrl: String?,
19
+ headers: [String: String]?,
20
+ query: [String: String]?,
21
+ body: String?,
22
+ contentType: String?,
23
+ userAgent: String?,
24
+ )
25
+ }
26
+
27
+ /// How Request-Config Layers compose, least specific first, each over the one before. The one
28
+ /// implementation of these rules; Android and the web stub follow the same ones.
29
+ enum RequestLayer {
30
+ /// `override` over `base`, field by field; headers and query merge by key.
31
+ static func merge<C: RequestFields>(_ base: C, _ override: C) -> C {
32
+ C(
33
+ method: override.method ?? base.method,
34
+ path: override.path ?? base.path,
35
+ baseUrl: override.baseUrl ?? base.baseUrl,
36
+ headers: mergeDicts(base.headers, override.headers),
37
+ query: mergeDicts(base.query, override.query),
38
+ body: override.body ?? base.body,
39
+ contentType: override.contentType ?? base.contentType,
40
+ userAgent: override.userAgent ?? base.userAgent,
41
+ )
42
+ }
43
+
44
+ /// A layer's static fields over `base`, keeping the base's path: only a transform may change
45
+ /// the path.
46
+ static func applyStatic<C: RequestFields>(_ layer: C, to base: C) -> C {
47
+ let merged = merge(base, layer)
48
+ return C(
49
+ method: merged.method, path: base.path, baseUrl: merged.baseUrl, headers: merged.headers,
50
+ query: merged.query, body: merged.body, contentType: merged.contentType, userAgent: merged.userAgent,
51
+ )
52
+ }
53
+
54
+ /// Applies one layer: its transforms, when it has any, win completely, each handed the result
55
+ /// of the one before; otherwise its static fields apply over `base`.
56
+ nonisolated(nonsending) static func apply<C: RequestFields>(
57
+ _ layer: C,
58
+ transforms: [nonisolated(nonsending)(C) async throws -> C],
59
+ to base: C,
60
+ ) async throws -> C {
61
+ guard !transforms.isEmpty else { return applyStatic(layer, to: base) }
62
+ var result = base
63
+ for transform in transforms {
64
+ result = try await transform(result)
65
+ }
66
+ return result
67
+ }
68
+
69
+ /// The configs a `resolve` (async) and `resolveSync` produced, the sync one winning; nil when
70
+ /// neither produced one.
71
+ static func mergeResolved<C: RequestFields>(async asyncResolved: C?, sync syncResolved: C?) -> C? {
72
+ guard let asyncResolved else { return syncResolved }
73
+ guard let syncResolved else { return asyncResolved }
74
+ return merge(asyncResolved, syncResolved)
75
+ }
76
+
77
+ /// The URL a config addresses: base URL, then path (or `defaultPath`), then query.
78
+ static func buildUrl(_ config: some RequestFields, defaultPath: String = "") -> String {
79
+ let url = BrowserPathHelper.buildUrl(baseUrl: config.baseUrl, path: config.path ?? defaultPath)
80
+ guard let query = config.query, !query.isEmpty else { return url }
81
+ return BrowserPathHelper.appendQuery(query, to: url)
82
+ }
83
+
84
+ /// `override`'s keys over `base`'s; nil only when both are nil.
85
+ static func mergeDicts(_ base: [String: String]?, _ override: [String: String]?) -> [String: String]? {
86
+ guard let base else { return override }
87
+ guard let override else { return base }
88
+ return base.merging(override) { _, new in new }
89
+ }
90
+ }