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
@@ -3,66 +3,35 @@ import NitroModules
3
3
  import os.log
4
4
 
5
5
  /// Custom browser errors.
6
- enum BrowserError: Error {
6
+ enum BrowserError: LocalizedError {
7
7
  case contentNotFound(path: String)
8
- case httpError(code: Int, body: String)
9
- case networkError(Error)
10
8
  case invalidConfiguration(String)
11
9
  case callbackError(String)
12
10
 
13
- var localizedDescription: String {
11
+ var errorDescription: String? {
14
12
  switch self {
15
- case .contentNotFound:
16
- return "Content not found"
17
- case let .httpError(code, body):
18
- // Try to extract error message from JSON response: { "error": "message" }
19
- if let data = body.data(using: .utf8),
20
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
21
- let errorMessage = json["error"] as? String
22
- {
23
- return errorMessage
24
- }
25
- // Fall back to localized HTTP status description
26
- return HTTPURLResponse.localizedString(forStatusCode: code)
27
- case let .networkError(error):
28
- return "Network error: \(error.localizedDescription)"
29
- case let .invalidConfiguration(message):
30
- return "Invalid configuration: \(message)"
31
- case let .callbackError(message):
32
- return message
13
+ case .contentNotFound: "Content not found"
14
+ case let .invalidConfiguration(message): "Invalid configuration: \(message)"
15
+ case let .callbackError(message): message
33
16
  }
34
17
  }
35
18
  }
36
19
 
37
- /// Core browser manager that handles navigation, search, and media browsing.
38
- ///
39
- /// This class contains the main business logic for:
40
- /// - Route resolution and path matching with parameter extraction
41
- /// - HTTP API requests and response processing
42
- /// - JavaScript callback invocation
43
- /// - Fallback handling and error management
20
+ /// The browser's state (path, content, tabs, config), its content and search caches, favorites
21
+ /// and events. Pages come from `PageResolver`; requests go through `RequestPipeline`.
44
22
  @MainActor
45
23
  final class BrowserManager {
46
24
  // Allow creation from nonisolated contexts (e.g. HybridAudioBrowser property default)
47
- nonisolated init() {}
25
+ nonisolated init() {
26
+ pages = PageResolver(requests: requests)
27
+ }
48
28
 
49
29
  let logger = Logger(subsystem: "com.audiobrowser", category: "BrowserManager")
50
30
 
51
- // MARK: - Constants (match Kotlin companion object)
52
-
53
- /// Internal path used for the default/root browse source
54
- static let defaultRoutePath = "__default__"
55
-
56
- /// Internal path used for navigation tabs
57
- static let tabsRoutePath = "__tabs__"
58
-
59
- /// Internal path used for search
60
- static let searchRoutePath = "__search__"
61
-
62
31
  // MARK: - Private Properties
63
32
 
64
- private let router = SimpleRouter()
65
- private let httpClient = HttpClient()
33
+ let requests = RequestPipeline()
34
+ private let pages: PageResolver
66
35
 
67
36
  // LRU cache for resolved content - keyed by path
68
37
  private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
@@ -71,12 +40,6 @@ final class BrowserManager {
71
40
  private var lastSearchKey: String?
72
41
  private var lastSearchResults: [Track]?
73
42
 
74
- // Resolver caching: request/browse thunks resolve once per content generation.
75
- private var layerGeneration = 0
76
- private var resolvedLayerGeneration = -1
77
- var resolvedRequestLayer: TransformableRequestConfig?
78
- private var resolvedBrowseLayer: TransformableRequestConfig?
79
-
80
43
  // Set of favorited track identities (id when non-blank, else src).
81
44
  private var favoriteIds: Set<String> = []
82
45
 
@@ -92,7 +55,7 @@ final class BrowserManager {
92
55
  private(set) var path: String = "/" {
93
56
  didSet {
94
57
  if oldValue != path {
95
- onPathChanged?(path)
58
+ pathChanged.emit(path)
96
59
  }
97
60
  }
98
61
  }
@@ -100,7 +63,7 @@ final class BrowserManager {
100
63
  private(set) var content: ResolvedTrack? {
101
64
  didSet {
102
65
  // Note: Can't compare ResolvedTrack directly, always fire callback
103
- onContentChanged?(content)
66
+ contentChanged.emit(content)
104
67
  }
105
68
  }
106
69
 
@@ -111,7 +74,7 @@ final class BrowserManager {
111
74
  // titles in a new locale), and an unchanged result must not churn the
112
75
  // CarPlay tab bar or reset its selection.
113
76
  if let tabs, !TabBarEntries.same(oldValue, tabs) {
114
- onTabsChanged?(tabs)
77
+ tabsChanged.emit(tabs)
115
78
  }
116
79
  }
117
80
  }
@@ -121,35 +84,38 @@ final class BrowserManager {
121
84
  /// Whether configureBrowser() has been called
122
85
  private(set) var isConfigured = false
123
86
 
124
- /// Browser configuration containing routes, search, tabs, and request settings.
125
- var config: BrowserConfig = .init() {
126
- didSet {
87
+ /// Browser configuration containing routes, search, tabs, and request settings. Kept on
88
+ /// `requests`, which every request reads.
89
+ var config: BrowserConfig {
90
+ get { requests.config }
91
+ set {
127
92
  isConfigured = true
128
- // New config means the cached resolver results are stale.
129
- layerGeneration += 1
130
- onConfigChanged?(config)
93
+ requests.config = newValue
94
+ configChanged.emit(newValue)
131
95
  }
132
96
  }
133
97
 
134
- // MARK: - Callbacks
98
+ // MARK: - Events (emitted on the main actor)
135
99
 
136
- var onPathChanged: ((String) -> Void)?
137
- var onContentChanged: ((ResolvedTrack?) -> Void)?
138
- var onTabsChanged: (([Track]) -> Void)?
139
- var onConfigChanged: ((BrowserConfig) -> Void)?
140
- /// Fired when the favorite id set changes (e.g. an app/webview-originated
141
- /// favorite). Lets surfaces like the CarPlay now-playing heart re-render —
142
- /// they otherwise only learn of their own toggles via favorite/active-track
143
- /// events, never an external change routed through `setFavorites`.
144
- var onFavoritesChanged: (() -> Void)?
100
+ let pathChanged = Emitter<String>()
101
+ let contentChanged = Emitter<ResolvedTrack?>()
102
+ let tabsChanged = Emitter<[Track]>()
103
+ let configChanged = Emitter<BrowserConfig>()
104
+ /// The favorite id set changed, however it changed (e.g. from the app or a webview), so
105
+ /// surfaces like the CarPlay now-playing heart can re-render.
106
+ let favoritesChanged = Emitter<Void>()
145
107
 
146
- /// Forwards to config.awaitTrackLoadHandler so callers don't need to
147
- /// cross the MainActor boundary to access `config`.
108
+ /// Runs the app's `handleTrackLoad` for `event` and waits for it, both promise layers: a JS
109
+ /// callback returning `Promise<void>` arrives as `Promise<Promise<Void>>`. False when the app
110
+ /// set none.
148
111
  func awaitTrackLoadHandler(event: TrackLoadEvent) async -> Bool {
149
- // awaitTrackLoadHandler manages its own MainActor dispatch internally
150
- nonisolated(unsafe) let cfg = config
151
- nonisolated(unsafe) let evt = event
152
- return await cfg.awaitTrackLoadHandler(event: evt)
112
+ guard let handler = config.handleTrackLoad else { return false }
113
+ do {
114
+ try await handler(event).await().await()
115
+ } catch {
116
+ logger.error("handleTrackLoad failed: \(error.localizedDescription)")
117
+ }
118
+ return true
153
119
  }
154
120
 
155
121
  // MARK: - Favorites
@@ -159,7 +125,7 @@ final class BrowserManager {
159
125
  let newIds = Set(favorites)
160
126
  guard newIds != favoriteIds else { return }
161
127
  favoriteIds = newIds
162
- onFavoritesChanged?()
128
+ favoritesChanged.emit(())
163
129
  }
164
130
 
165
131
  /// Enables/disables favoriting (propagated from the `favorite` capability).
@@ -206,30 +172,25 @@ final class BrowserManager {
206
172
 
207
173
  // MARK: - Navigation
208
174
 
209
- /// Main navigation method - updates path and resolves content.
210
- ///
211
- /// Uses a navigation ID to prevent race conditions when multiple navigations
212
- /// overlap. Only the most recent navigation's result is applied.
213
- func navigate(_ path: String) async throws {
214
- // Increment navigation ID and capture for this navigation
175
+ /// Loads `path` as the current page. `bypassCache` reloads it from the network and keeps the
176
+ /// content on screen meanwhile, for refreshing the current path. A newer load supersedes this one, and its
177
+ /// result or failure is dropped: every caller reports a throw, so a slow /a failing after a
178
+ /// fast /b succeeded would otherwise show /a's error over /b's content.
179
+ func load(_ path: String, bypassCache: Bool = false) async throws {
215
180
  currentNavigationId += 1
216
181
  let navigationId = currentNavigationId
217
-
218
182
  self.path = path
219
- content = nil // Clear for loading state
220
-
183
+ if bypassCache {
184
+ // A failed reload must not leave the stale page to be served from the cache.
185
+ contentCache.remove(path)
186
+ } else {
187
+ content = nil
188
+ }
221
189
  do {
222
- let resolved = try await resolve(path)
223
-
224
- // Only apply result if this is still the current navigation
190
+ let resolved = try await resolve(path, useCache: !bypassCache)
225
191
  guard navigationId == currentNavigationId else { return }
226
-
227
192
  content = resolved
228
193
  } catch {
229
- // A superseded navigation's failure is not the current page's failure.
230
- // Every caller reports a throw unconditionally (and CarPlay presents it as
231
- // a modal action sheet), so without this a slow /a that fails after a fast
232
- // /b succeeded would show /a's error over /b's content.
233
194
  guard navigationId == currentNavigationId else { return }
234
195
  throw error
235
196
  }
@@ -245,7 +206,7 @@ final class BrowserManager {
245
206
  return hydrateChildren(cached)
246
207
  }
247
208
 
248
- let resolvedTrack = try await resolveUncached(normalizedPath)
209
+ let resolvedTrack = try await pages.resolve(normalizedPath)
249
210
 
250
211
  // Cache the resolved content for future navigation
251
212
  contentCache.set(normalizedPath, value: resolvedTrack)
@@ -273,7 +234,7 @@ final class BrowserManager {
273
234
  /// Runs from `invalidateAllContent`, so re-resolve the layer thunks too.
274
235
  func clearContentCache() {
275
236
  contentCache.clear()
276
- layerGeneration += 1
237
+ requests.invalidateLayers()
277
238
  // The search slot is content too: a locale switch or a re-auth changes
278
239
  // titles, src, artwork and availability, and nothing else ever evicts it,
279
240
  // so a repeated query returned pre-invalidation results forever.
@@ -281,320 +242,15 @@ final class BrowserManager {
281
242
  lastSearchResults = nil
282
243
  }
283
244
 
284
- /// Refreshes the current path by invalidating cache and re-resolving.
285
- ///
286
- /// Uses navigation ID tracking to prevent race conditions.
287
- func refresh() async throws {
288
- // Increment navigation ID and capture for this refresh
289
- currentNavigationId += 1
290
- let navigationId = currentNavigationId
291
-
292
- let currentPath = path
293
- contentCache.remove(currentPath)
294
-
295
- do {
296
- let resolved = try await resolve(currentPath, useCache: false)
297
-
298
- // Only apply result if this is still the current navigation
299
- guard navigationId == currentNavigationId else { return }
300
-
301
- content = resolved
302
- } catch {
303
- // Superseded refreshes stay silent, for the same reason as `navigate`.
304
- guard navigationId == currentNavigationId else { return }
305
- throw error
306
- }
307
- }
308
-
309
- private func resolveUncached(_ path: String) async throws -> ResolvedTrack {
310
- // Match an explicit route (or the '*' default). With no match, fall back to
311
- // the implicit default: fetch the path via the request + browse config.
312
- let routeEntry = (config.routes).flatMap { findBestRouteMatch(path: path, routes: $0) }
313
-
314
- let resolvedTrack: ResolvedTrack = if let (entry, routeMatch) = routeEntry {
315
- try await resolveRouteEntry(entry, path: path, params: routeMatch.params)
316
- } else {
317
- // Implicit default — no route config, just request → browse → fetch path.
318
- try await resolveFromConfig(
319
- nil, path: path, params: ["path": path],
320
- )
321
- }
322
-
323
- // Normalize to the canonical sectioned shape (children sugar → one
324
- // untitled section — ADR 0010), then validate and transform. The output
325
- // never carries `children`.
326
- if let sections = resolvedTrack.normalizedSections {
327
- // Dev diagnostic (ADR 0011) — before the blocks are folded, while each
328
- // level is still attributable. Every page passes here whatever its
329
- // source, and only on a cache miss. No-op outside debug builds.
330
- InertStyleDiagnostic.warn(path: path, pageStyle: resolvedTrack.style, sections: sections)
331
- let transformed = try await transformSections(
332
- sections, parentPath: path, routeEntry: routeEntry?.0,
333
- )
334
- return resolvedTrack.copying(sections: transformed, children: .some(nil))
335
- }
336
-
337
- return resolvedTrack
338
- }
339
-
340
- // MARK: - Route Resolution
341
-
342
- private func findBestRouteMatch(
343
- path: String,
344
- routes: [NativeRouteEntry],
345
- ) -> (NativeRouteEntry, RouteMatch)? {
346
- // Filter out special routes
347
- let browseRoutes = routes.filter {
348
- $0.path != Self.tabsRoutePath &&
349
- $0.path != Self.searchRoutePath &&
350
- $0.path != Self.defaultRoutePath
351
- }
352
-
353
- // Try to find a matching route
354
- if let match = router.findBestMatch(path: path, routes: browseRoutes) {
355
- return match
356
- }
357
-
358
- // Fall back to __default__ if present
359
- if let defaultRoute = routes.first(where: { $0.path == Self.defaultRoutePath }) {
360
- return (defaultRoute, RouteMatch(params: ["path": path]))
361
- }
362
-
363
- return nil
364
- }
365
-
366
- private func resolveRouteEntry(
367
- _ entry: NativeRouteEntry,
368
- path: String,
369
- params: [String: String],
370
- ) async throws -> ResolvedTrack {
371
- // Priority: callback > config > static
372
- if let callback = entry.browseCallback {
373
- let callbackParam = BrowserSourceCallbackParam(path: path, routeParams: params)
374
- // BrowserSourceCallback may return a BrowseResult synchronously or via a
375
- // Promise. Nitro flattens (ResolvedTrack | BrowseError) | Promise<BrowseResult>
376
- // into a 3-arm variant: sync track, sync error, or a Promise resolving to a
377
- // BrowseResult (which is itself a ResolvedTrack | BrowseError variant).
378
- switch try await callback(callbackParam).await() {
379
- case let .first(resolvedTrack):
380
- return resolvedTrack
381
- case let .second(browseError):
382
- throw BrowserError.callbackError(browseError.error)
383
- case let .third(promise):
384
- switch try await promise.await() {
385
- case let .first(resolvedTrack):
386
- return resolvedTrack
387
- case let .second(browseError):
388
- throw BrowserError.callbackError(browseError.error)
389
- }
390
- }
391
- }
392
-
393
- if let browseConfig = entry.browseConfig {
394
- return try await resolveFromConfig(
395
- browseConfig, path: path, params: params,
396
- )
397
- }
398
-
399
- if let staticContent = entry.browseStatic {
400
- return staticContent
401
- }
402
-
403
- throw BrowserError.contentNotFound(path: path)
404
- }
405
-
406
- /// Builds the HTTP request for an API-backed route by layering
407
- /// request → kind → route configs. `initialQuery` seeds request-level query
408
- /// params (e.g. search q/mode/…) onto the base before the route layer, so they
409
- /// survive into `request.query` even when a layer's transform "wins
410
- /// completely" (it is handed only the base, so the route config's own static
411
- /// query would otherwise be dropped). `routeConfig` is nil for the implicit
412
- /// browse default; `kindConfig` is nil for kinds with no per-kind config.
413
- private func buildApiRequest(
414
- kind kindConfig: TransformableRequestConfig?,
415
- _ routeConfig: TransformableRequestConfig?,
416
- path: String,
417
- params: [String: String],
418
- initialQuery: [String: String]? = nil,
419
- ) async throws -> HttpClient.HttpRequest {
420
- try await ensureLayersResolved()
421
-
422
- var merged = RequestConfig(
423
- method: nil, path: path, baseUrl: nil, headers: nil,
424
- query: nil, body: nil, contentType: nil, userAgent: nil,
245
+ /// `error` as the app's `formatNavigationError` displays it, or its default display when
246
+ /// there is no formatter or it fails.
247
+ func formatted(_ error: NavigationError, path: String) async -> FormattedNavigationError {
248
+ let fallback = error.defaultFormatted()
249
+ guard let formatter = config.formatNavigationError else { return fallback }
250
+ let params = FormatNavigationErrorParams(
251
+ error: error, defaultFormatted: fallback, path: path, kind: NavigationErrorKind(path: path),
425
252
  )
426
- merged = try await applyLayer(resolvedRequestLayer, to: merged, params: params)
427
- if let initialQuery, !initialQuery.isEmpty {
428
- merged = merged.copying(query: mergeDicts(merged.query, initialQuery))
429
- }
430
- merged = try await applyLayer(kindConfig, to: merged, params: params)
431
- merged = try await applyLayer(routeConfig, to: merged, params: params)
432
-
433
- guard let baseUrl = merged.baseUrl else {
434
- throw BrowserError.invalidConfiguration("No URL configured for route")
435
- }
436
- var url = BrowserPathHelper.buildUrl(baseUrl: baseUrl, path: merged.path ?? path)
437
- if let query = merged.query, !query.isEmpty {
438
- url = BrowserPathHelper.appendQuery(query, to: url)
439
- }
440
-
441
- return HttpClient.HttpRequest(
442
- url: url,
443
- method: merged.method?.stringValue ?? "GET",
444
- headers: merged.headers,
445
- body: merged.body,
446
- contentType: merged.contentType ?? HttpClient.defaultContentType,
447
- userAgent: merged.userAgent ?? HttpClient.defaultUserAgent,
448
- )
449
- }
450
-
451
- /// Resolves a browse route: a page object (`{title,path,children:[…]}`).
452
- /// The browse kind layer is the cached resolved layer (`resolvedBrowseLayer`),
453
- /// populated by `ensureLayersResolved()` inside `buildApiRequest`.
454
- private func resolveFromConfig(
455
- _ routeConfig: TransformableRequestConfig?,
456
- path: String,
457
- params: [String: String],
458
- ) async throws -> ResolvedTrack {
459
- try await ensureLayersResolved()
460
- let request = try await buildApiRequest(
461
- kind: resolvedBrowseLayer, routeConfig, path: path, params: params,
462
- )
463
-
464
- logger.debug("Resolving content from API")
465
- logger.debug(" path: \(path)")
466
- logger.debug(" url: \(request.url)")
467
-
468
- let result: JsonResolvedTrack = try await httpClient.requestJson(request, as: JsonResolvedTrack.self)
469
- let nitroResult = result.toNitro()
470
-
471
- logger.debug("Resolved: \(nitroResult.title)")
472
- if let sections = nitroResult.normalizedSections {
473
- logger.debug(" children: \(sections.reduce(0) { $0 + $1.children.count }) tracks")
474
- }
475
-
476
- return nitroResult
477
- }
478
-
479
- /// Applies one request-config layer (request / browse / route) onto a base.
480
- /// A transform (async and/or sync) wins completely: it receives the base and its
481
- /// result replaces it. When both are set they run as a pipeline — async first,
482
- /// then sync. Otherwise the layer's static fields merge over the base. `path` is
483
- /// carried from the base (only a transform may change it).
484
- func applyLayer(
485
- _ layer: TransformableRequestConfig?,
486
- to base: RequestConfig,
487
- params: [String: String],
488
- ) async throws -> RequestConfig {
489
- guard let layer else { return base }
490
- // Run-both transform: async first, then sync (each replaces the running config).
491
- // The bridge await depth — the bug-prone part — is centralised in awaitAsync/SyncConfig.
492
- if layer.transform != nil || layer.transformSync != nil {
493
- var result = base
494
- if let transform = layer.transform { result = try await awaitAsyncConfig(transform(result, params)) }
495
- if let transformSync = layer.transformSync { result = try await awaitSyncConfig(transformSync(result, params)) }
496
- return result
497
- }
498
- return RequestConfig(
499
- method: layer.method ?? base.method,
500
- path: base.path,
501
- baseUrl: layer.baseUrl ?? base.baseUrl,
502
- headers: mergeDicts(base.headers, layer.headers),
503
- query: mergeDicts(base.query, layer.query),
504
- body: layer.body ?? base.body,
505
- contentType: layer.contentType ?? base.contentType,
506
- userAgent: layer.userAgent ?? base.userAgent,
507
- )
508
- }
509
-
510
- /// Merges two optional dictionaries, with override values taking precedence.
511
- /// Delegates to the pure, unit-tested `MediaResolveComposer.mergeDicts`.
512
- func mergeDicts(
513
- _ base: [String: String]?,
514
- _ override: [String: String]?,
515
- ) -> [String: String]? {
516
- MediaResolveComposer.mergeDicts(base, override)
517
- }
518
-
519
- // MARK: - Layer Resolvers
520
-
521
- /// Resolve one layer: invoke its resolver if present, else return the static
522
- /// layer config unchanged. The resolver is Promise-only (the TS layer wraps a
523
- /// sync-or-async thunk in Promise.resolve), so its result is the JS promise
524
- /// behind the bridge promise — a double await.
525
- private func resolveLayer(
526
- config staticConfig: TransformableRequestConfig?,
527
- resolver: (() -> Promise<Promise<TransformableRequestConfig>>)?,
528
- ) async throws -> TransformableRequestConfig? {
529
- guard let resolver else { return staticConfig }
530
- return try await resolver().await().await()
531
- }
532
-
533
- /// Ensure request/browse resolvers are resolved for the current generation,
534
- /// caching the results. Re-resolves after a generation bump (config change /
535
- /// invalidateAllContent). Idempotent within a generation.
536
- func ensureLayersResolved() async throws {
537
- guard resolvedLayerGeneration != layerGeneration else { return }
538
- let generation = layerGeneration
539
- let req = try await resolveLayer(config: config.request, resolver: config.requestResolver)
540
- let brw = try await resolveLayer(config: config.browse, resolver: config.browseResolver)
541
- // Only commit if still current (a newer generation may have started during await).
542
- guard generation == layerGeneration else { return }
543
- resolvedRequestLayer = req
544
- resolvedBrowseLayer = brw
545
- resolvedLayerGeneration = generation
546
- }
547
-
548
- // MARK: - Child Transformation
549
-
550
- private func transformSections(
551
- _ sections: [Section],
552
- parentPath: String,
553
- routeEntry: NativeRouteEntry?,
554
- ) async throws -> [Section] {
555
- var transformed: [Section] = []
556
- // The contextual index is the flat page position — children concatenated
557
- // in section order (ADR 0009/0010).
558
- var flatIndex = 0
559
-
560
- for section in sections {
561
- var transformedChildren: [Track] = []
562
-
563
- for track in section.children {
564
- let index = flatIndex
565
- flatIndex += 1
566
-
567
- // Validate track has stable identifier.
568
- if track.path == nil, track.src == nil {
569
- throw BrowserError.invalidConfiguration(
570
- "Track must have either 'path' or 'src' for stable identification: \(track.title)",
571
- )
572
- }
573
-
574
- var transformedTrack = track
575
-
576
- if track.src != nil, track.path == nil {
577
- // src != nil guarantees a non-nil identity (id when non-blank, else src).
578
- // The flat page position rides along as a duplicate-identity tie-breaker.
579
- let contextualPath = BrowserPathHelper.build(
580
- parentPath: parentPath, trackId: track.identity!, index: index,
581
- )
582
- transformedTrack = track.copying(path: contextualPath)
583
- }
584
-
585
- // Resolve artwork URL at browse-time (no size context)
586
- let artworkConfig = routeEntry?.artwork ?? config.artwork
587
- if let imageSource = await resolveArtworkUrl(track: transformedTrack, perRouteConfig: artworkConfig) {
588
- transformedTrack = transformedTrack.copying(artworkSource: imageSource)
589
- }
590
-
591
- transformedChildren.append(transformedTrack)
592
- }
593
-
594
- transformed.append(section.copying(children: transformedChildren))
595
- }
596
-
597
- return transformed
253
+ return await (try? formatter(params).await()) ?? fallback
598
254
  }
599
255
 
600
256
  // MARK: - Search
@@ -607,29 +263,10 @@ final class BrowserManager {
607
263
  )
608
264
  }
609
265
 
610
- /// The query params a structured search sends: `q` plus whichever filters are
611
- /// set. Mirrors Android's `executeSearchApiRequest` and the web stub.
612
- ///
613
- /// Single source of truth for "what distinguishes one search from another" —
614
- /// the request sends exactly this, and the cache keys on exactly this, so a
615
- /// field added here can't be forgotten in one place and silently serve one
616
- /// search's results for another.
617
- private static func searchQueryParams(_ params: SearchParams) -> [String: String] {
618
- var query: [String: String] = ["q": params.query]
619
- if let mode = params.mode { query["mode"] = mode.stringValue }
620
- if params.reference == .my { query["reference"] = "my" }
621
- if let genre = params.genre { query["genre"] = genre }
622
- if let artist = params.artist { query["artist"] = artist }
623
- if let album = params.album { query["album"] = album }
624
- if let title = params.title { query["title"] = title }
625
- if let playlist = params.playlist { query["playlist"] = playlist }
626
- return query
627
- }
628
-
629
266
  /// The cache identity of a search. Sorted so key order is stable, and
630
267
  /// `\u{1}`-delimited so two different param sets can't serialize alike.
631
268
  private static func searchCacheKey(_ params: SearchParams) -> String {
632
- searchQueryParams(params)
269
+ PageResolver.searchQueryParams(params)
633
270
  .sorted { $0.key < $1.key }
634
271
  .map { "\($0.key)=\($0.value)" }
635
272
  .joined(separator: "\u{1}")
@@ -652,38 +289,7 @@ final class BrowserManager {
652
289
  return makeSearchResult(query: query, results: results)
653
290
  }
654
291
 
655
- guard let routes = config.routes else {
656
- throw BrowserError.contentNotFound(path: Self.searchRoutePath)
657
- }
658
-
659
- guard let searchEntry = routes.first(where: { $0.path == Self.searchRoutePath }) else {
660
- throw BrowserError.contentNotFound(path: Self.searchRoutePath)
661
- }
662
-
663
- var results: [Track]
664
-
665
- if let callback = searchEntry.searchCallback {
666
- let outerPromise = callback(params)
667
- let innerPromise = try await outerPromise.await()
668
- results = try await innerPromise.await()
669
- } else if let searchConfig = searchEntry.searchConfig {
670
- // Search is its own kind — request → search route (no browse layer). The
671
- // endpoint returns a bare Track array (unlike browse's page object). The
672
- // search params go through `initialQuery` so they land in `request.query`
673
- // (and the URL); a config with a transform only ever sees the base.
674
- // Seed the search config's static path onto the base — a layer's static
675
- // path never applies (carried from the base; only a transform may change
676
- // it). Matches the web stub's fetchSearchResults and Android.
677
- let request = try await buildApiRequest(
678
- kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": query],
679
- initialQuery: Self.searchQueryParams(params),
680
- )
681
- logger.debug("Searching via API: \(request.url)")
682
- let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
683
- results = jsonTracks.map { $0.toNitro() }
684
- } else {
685
- throw BrowserError.contentNotFound(path: Self.searchRoutePath)
686
- }
292
+ let results = try await pages.search(params)
687
293
 
688
294
  // Cache the RAW results, never the hydrated ones: hydration is one-shot
689
295
  // (`hydrateFavorite` skips a track whose `favorited` is already set), so a
@@ -746,20 +352,9 @@ final class BrowserManager {
746
352
 
747
353
  /// Query navigation tabs.
748
354
  func queryTabs() async throws -> [Track] {
749
- guard let routes = config.routes else {
750
- return []
751
- }
752
-
753
- guard let tabsEntry = routes.first(where: { $0.path == Self.tabsRoutePath }) else {
754
- return []
755
- }
756
-
757
- let resolved = try await resolveRouteEntry(tabsEntry, path: Self.tabsRoutePath, params: [:])
758
- // Tabs are a flat list, not a page — a sectioned tabs source flattens.
759
- let tabTracks = resolved.flattenedChildren ?? []
760
-
761
- tabs = tabTracks
762
- return tabTracks
355
+ guard let tabs = try await pages.tabs() else { return [] }
356
+ self.tabs = tabs
357
+ return tabs
763
358
  }
764
359
 
765
360
  // MARK: - Queue Expansion (for CarPlay/external controllers)
@@ -827,20 +422,6 @@ final class BrowserManager {
827
422
 
828
423
  return (tracks: playableTracks, selectedIndex: selectedIndex)
829
424
  }
830
-
831
- // MARK: - Accessors
832
-
833
- func getPath() -> String {
834
- path
835
- }
836
-
837
- func getContent() -> ResolvedTrack? {
838
- content
839
- }
840
-
841
- func getTabs() -> [Track]? {
842
- tabs
843
- }
844
425
  }
845
426
 
846
427
  // MARK: - TrackSelectionBrowser