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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /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:
|
|
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
|
|
11
|
+
var errorDescription: String? {
|
|
14
12
|
switch self {
|
|
15
|
-
case .contentNotFound:
|
|
16
|
-
|
|
17
|
-
case let .
|
|
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
|
-
///
|
|
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
|
-
|
|
65
|
-
private let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
onConfigChanged?(config)
|
|
93
|
+
requests.config = newValue
|
|
94
|
+
configChanged.emit(newValue)
|
|
131
95
|
}
|
|
132
96
|
}
|
|
133
97
|
|
|
134
|
-
// MARK: -
|
|
98
|
+
// MARK: - Events (emitted on the main actor)
|
|
135
99
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
///
|
|
141
|
-
///
|
|
142
|
-
|
|
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
|
-
///
|
|
147
|
-
///
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
210
|
-
///
|
|
211
|
-
///
|
|
212
|
-
///
|
|
213
|
-
func
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
///
|
|
285
|
-
///
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
750
|
-
|
|
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
|