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
|
@@ -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
|
+
}
|