react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.33
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/ios/AVPlayerEngine.swift +4 -8
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +172 -381
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +5 -21
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/Model/NitroTypeStubs.swift +78 -0
- package/ios/Player/AudioSessionController.swift +2 -0
- package/ios/Player/ErrorStatus.swift +25 -2
- package/ios/Player/MediaLoader.swift +23 -71
- package/ios/Player/NowPlayingLines.swift +36 -0
- package/ios/Player/NowPlayingUpdater.swift +27 -85
- package/ios/Player/Player.swift +108 -261
- package/ios/Player/RetryLoop.swift +26 -0
- package/ios/Player/SleepTimerManager.swift +13 -15
- package/ios/Player/VolumeFader.swift +5 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/ErrorStatusTests.swift +50 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/JsonModelsTests.swift +30 -0
- package/ios/Tests/MediaLoaderTests.swift +26 -21
- package/ios/Tests/NowPlayingLinesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +19 -3
- package/ios/Tests/PlayerTests.swift +81 -74
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/RetryLoopTests.swift +35 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/ios/Tests/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@ final class RequestPipeline {
|
|
|
30
30
|
|
|
31
31
|
/// The request and browse layers of the current config: each resolver's result, or the
|
|
32
32
|
/// static layer when it has none. The resolvers run one after the other, request first, as on
|
|
33
|
-
/// Android and web, and a config change or `
|
|
33
|
+
/// Android and web, and a config change or `invalidateAll` while they run resolves them
|
|
34
34
|
/// again.
|
|
35
35
|
func resolvedLayers() async throws -> ResolvedLayers {
|
|
36
36
|
try await layers.value { [weak self] in
|
|
@@ -45,12 +45,12 @@ final class RequestPipeline {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/// Applies one Request-Config Layer (request / browse / route) onto `base`: its
|
|
49
|
-
/// (async, then sync) when it has any, else its static fields (`RequestLayer.apply`).
|
|
48
|
+
/// Applies one Request-Config Layer (request / browse / media / route) onto `base`: its
|
|
49
|
+
/// transforms (async, then sync) when it has any, else its static fields (`RequestLayer.apply`).
|
|
50
50
|
func applyLayer(
|
|
51
|
-
_ layer:
|
|
51
|
+
_ layer: (some TransformableLayer)?,
|
|
52
52
|
to base: RequestConfig,
|
|
53
|
-
params: [String: String]
|
|
53
|
+
params: [String: String]?,
|
|
54
54
|
) async throws -> RequestConfig {
|
|
55
55
|
guard let layer else { return base }
|
|
56
56
|
var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
|
|
@@ -60,11 +60,7 @@ final class RequestPipeline {
|
|
|
60
60
|
if let transformSync = layer.transformSync {
|
|
61
61
|
transforms.append { try await self.awaitSyncConfig(transformSync($0, params)) }
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
method: layer.method, path: nil, baseUrl: layer.baseUrl, headers: layer.headers,
|
|
65
|
-
query: layer.query, body: layer.body, contentType: layer.contentType, userAgent: layer.userAgent,
|
|
66
|
-
)
|
|
67
|
-
return try await RequestLayer.apply(fields, transforms: transforms, to: base)
|
|
63
|
+
return try await RequestLayer.apply(RequestConfig(fieldsOf: layer), transforms: transforms, to: base)
|
|
68
64
|
}
|
|
69
65
|
|
|
70
66
|
/// Builds the HTTP request for an API-backed route by layering
|
|
@@ -82,10 +78,7 @@ final class RequestPipeline {
|
|
|
82
78
|
params: [String: String],
|
|
83
79
|
initialQuery: [String: String]? = nil,
|
|
84
80
|
) async throws -> HttpClient.HttpRequest {
|
|
85
|
-
var merged = RequestConfig(
|
|
86
|
-
method: nil, path: path, baseUrl: nil, headers: nil,
|
|
87
|
-
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
88
|
-
)
|
|
81
|
+
var merged = RequestConfig.base(path: path)
|
|
89
82
|
let layers = try await resolvedLayers()
|
|
90
83
|
merged = try await applyLayer(layers.request, to: merged, params: params)
|
|
91
84
|
if let initialQuery, !initialQuery.isEmpty {
|
|
@@ -99,7 +92,7 @@ final class RequestPipeline {
|
|
|
99
92
|
}
|
|
100
93
|
return HttpClient.HttpRequest(
|
|
101
94
|
url: RequestLayer.buildUrl(merged, defaultPath: path),
|
|
102
|
-
method: merged.method
|
|
95
|
+
method: merged.method ?? .get,
|
|
103
96
|
headers: merged.headers,
|
|
104
97
|
body: merged.body,
|
|
105
98
|
contentType: merged.contentType ?? HttpClient.defaultContentType,
|
|
@@ -139,7 +132,7 @@ final class RequestPipeline {
|
|
|
139
132
|
func fetchSearch(_ searchConfig: TransformableRequestConfig, params: SearchParams) async throws -> [Track] {
|
|
140
133
|
let request = try await buildApiRequest(
|
|
141
134
|
kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": params.query],
|
|
142
|
-
initialQuery:
|
|
135
|
+
initialQuery: params.queryParams,
|
|
143
136
|
)
|
|
144
137
|
logger.debug("Searching via API: \(request.url)")
|
|
145
138
|
let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
|
|
@@ -148,38 +141,18 @@ final class RequestPipeline {
|
|
|
148
141
|
|
|
149
142
|
// MARK: - Media URL Resolution
|
|
150
143
|
|
|
151
|
-
///
|
|
152
|
-
///
|
|
153
|
-
///
|
|
154
|
-
/// Resolution layers, least- to most-specific: shared `request` layer →
|
|
155
|
-
/// `media` transform / static fields → `media.resolve(track)`. The final
|
|
156
|
-
/// `resolve` layer (if configured) is merged last,
|
|
157
|
-
/// so its fields win. The `track` is only used to feed `resolve`; we do NOT
|
|
158
|
-
/// auto-merge `track.request` here.
|
|
144
|
+
/// The media request for `originalUrl`: the shared `request` layer, then the `media` layer,
|
|
145
|
+
/// then `media.resolve(track)` merged over them. `track` only feeds `resolve`; its own
|
|
146
|
+
/// `request` is not merged here. A failing layer leaves the URL as it is.
|
|
159
147
|
func resolveMediaUrl(_ originalUrl: String, track: Track? = nil) async -> MediaResolvedUrl {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// Apply the shared `request` layer first (its transform runs for media too,
|
|
163
|
-
// per the documented contract — e.g. a dynamic baseUrl), then the media
|
|
164
|
-
// transform / static fields on top. The request layer applies even when no
|
|
165
|
-
// `media` config is present, so a relative src still gets baseUrl.
|
|
166
|
-
let baseRequest: RequestConfig
|
|
148
|
+
let base: RequestConfig
|
|
167
149
|
do {
|
|
168
|
-
|
|
169
|
-
resolvedLayers().request,
|
|
170
|
-
to: RequestConfig(
|
|
171
|
-
method: nil, path: originalUrl, baseUrl: nil, headers: nil,
|
|
172
|
-
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
173
|
-
),
|
|
174
|
-
params: [:],
|
|
175
|
-
)
|
|
150
|
+
base = try await applyLayer(resolvedLayers().request, to: .base(path: originalUrl), params: [:])
|
|
176
151
|
} catch {
|
|
177
152
|
logger.error("Media request layer failed: \(error.localizedDescription)")
|
|
178
153
|
return MediaResolvedUrl(url: originalUrl, headers: nil, userAgent: nil)
|
|
179
154
|
}
|
|
180
155
|
|
|
181
|
-
// Resolve the consumer-supplied final media layer once, up front. This is the
|
|
182
|
-
// most-specific layer and is merged over every branch's result below.
|
|
183
156
|
let resolveLayer: RequestConfig?
|
|
184
157
|
do {
|
|
185
158
|
resolveLayer = try await resolveMediaTrackConfig(track)
|
|
@@ -188,228 +161,106 @@ final class RequestPipeline {
|
|
|
188
161
|
resolveLayer = nil
|
|
189
162
|
}
|
|
190
163
|
|
|
191
|
-
// request → media (kind layer) → media.resolve(track), resolve winning.
|
|
192
|
-
// Matches the web stub and Android. (The old static branch dropped a media
|
|
193
|
-
// config's static query/method/body/contentType, and dropped its headers
|
|
194
|
-
// entirely when no baseUrl was configured anywhere.)
|
|
195
164
|
do {
|
|
196
|
-
var merged =
|
|
197
|
-
if let mediaConfig = config.media {
|
|
198
|
-
merged = try await applyMediaLayer(mediaConfig, to: merged)
|
|
199
|
-
}
|
|
165
|
+
var merged = try await applyLayer(config.media, to: base, params: nil)
|
|
200
166
|
if let resolveLayer { merged = RequestLayer.merge(merged, resolveLayer) }
|
|
201
167
|
let finalUrl = RequestLayer.buildUrl(merged)
|
|
202
168
|
logger.debug("Media URL resolved: \(originalUrl) -> \(finalUrl)")
|
|
203
|
-
return MediaResolvedUrl(
|
|
204
|
-
url: finalUrl,
|
|
205
|
-
headers: merged.headers,
|
|
206
|
-
userAgent: merged.userAgent,
|
|
207
|
-
)
|
|
169
|
+
return MediaResolvedUrl(url: finalUrl, headers: merged.headers, userAgent: merged.userAgent)
|
|
208
170
|
} catch {
|
|
209
171
|
logger.error("Media transform failed: \(error.localizedDescription)")
|
|
210
172
|
return MediaResolvedUrl(url: originalUrl, headers: nil, userAgent: nil)
|
|
211
173
|
}
|
|
212
174
|
}
|
|
213
175
|
|
|
214
|
-
/// The
|
|
215
|
-
///
|
|
216
|
-
private func applyMediaLayer(_ media: MediaRequestConfig, to base: RequestConfig) async throws -> RequestConfig {
|
|
217
|
-
var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
|
|
218
|
-
if let transform = media.transform {
|
|
219
|
-
transforms.append { try await self.awaitAsyncConfig(transform($0, nil)) }
|
|
220
|
-
}
|
|
221
|
-
if let transformSync = media.transformSync {
|
|
222
|
-
transforms.append { try await self.awaitSyncConfig(transformSync($0, nil)) }
|
|
223
|
-
}
|
|
224
|
-
let fields = RequestConfig(
|
|
225
|
-
method: media.method, path: nil, baseUrl: media.baseUrl, headers: media.headers,
|
|
226
|
-
query: media.query, body: media.body, contentType: media.contentType, userAgent: media.userAgent,
|
|
227
|
-
)
|
|
228
|
-
return try await RequestLayer.apply(fields, transforms: transforms, to: base)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/// Invokes the consumer-supplied `media.resolve`/`resolveSync(track)` and merges
|
|
232
|
-
/// the results (async first, then sync, sync winning) into a concrete
|
|
233
|
-
/// `RequestConfig`. Returns `nil` when neither is configured or no track is
|
|
234
|
-
/// available. Each result is copied out of the Nitro bridge immediately.
|
|
176
|
+
/// The config the `media.resolve` / `resolveSync(track)` callbacks produce, nil when neither is
|
|
177
|
+
/// configured or there is no track.
|
|
235
178
|
func resolveMediaTrackConfig(_ track: Track?) async throws -> RequestConfig? {
|
|
236
179
|
guard let track, let media = config.media else { return nil }
|
|
180
|
+
return try await resolved(media.resolve, media.resolveSync, for: track)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// What a `resolve` (async) and `resolveSync` produce for `track`, the sync one winning.
|
|
184
|
+
private func resolved(
|
|
185
|
+
_ resolve: ((Track) -> Promise<Promise<RequestConfig>>)?,
|
|
186
|
+
_ resolveSync: ((Track) -> Promise<RequestConfig>)?,
|
|
187
|
+
for track: Track,
|
|
188
|
+
) async throws -> RequestConfig? {
|
|
237
189
|
var asyncResolved: RequestConfig?
|
|
238
|
-
if let resolve
|
|
190
|
+
if let resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
|
|
239
191
|
var syncResolved: RequestConfig?
|
|
240
|
-
if let resolveSync
|
|
192
|
+
if let resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
|
|
241
193
|
return RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved)
|
|
242
194
|
}
|
|
243
195
|
|
|
244
196
|
// MARK: - Artwork URL Resolution
|
|
245
197
|
|
|
246
|
-
///
|
|
247
|
-
///
|
|
248
|
-
///
|
|
249
|
-
///
|
|
250
|
-
///
|
|
251
|
-
/// - perRouteConfig: Optional per-route artwork config that overrides global config
|
|
252
|
-
/// - imageContext: Optional size context for CDN URL generation (nil at browse-time)
|
|
253
|
-
/// - Returns: ImageSource ready for image loading, or nil if no artwork
|
|
198
|
+
/// The image source for `track`'s artwork: an SF Symbol rendered to a file, the artwork URL
|
|
199
|
+
/// as is without an artwork config, else the shared `request` layer and the artwork config in
|
|
200
|
+
/// `RequestLayer.artwork`'s order. `perRouteConfig` replaces the global artwork config;
|
|
201
|
+
/// `imageContext` is the display size, nil at browse time. Nil when there is no artwork or a
|
|
202
|
+
/// callback fails.
|
|
254
203
|
func resolveArtworkUrl(track: Track, perRouteConfig: ArtworkRequestConfig?, imageContext: ImageContext? = nil) async -> ImageSource? {
|
|
255
|
-
|
|
204
|
+
let artworkUrl = track.artwork?.url
|
|
205
|
+
if let artworkUrl, SFSymbolRenderer.isSFSymbol(artworkUrl) {
|
|
256
206
|
let canvasSize: CGSize = if let w = imageContext?.width, let h = imageContext?.height {
|
|
257
207
|
CGSize(width: w, height: h)
|
|
258
208
|
} else {
|
|
259
209
|
SFSymbolRenderer.defaultCanvasSize
|
|
260
210
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
return nil
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Determine effective artwork config: per-route overrides global
|
|
268
|
-
let effectiveArtworkConfig = perRouteConfig ?? config.artwork
|
|
269
|
-
|
|
270
|
-
// If no artwork config and no track.artwork, nothing to transform
|
|
271
|
-
if effectiveArtworkConfig == nil, track.artwork == nil {
|
|
272
|
-
return nil
|
|
211
|
+
return SFSymbolRenderer.shared.render(artworkUrl, canvasSize: canvasSize)
|
|
212
|
+
.map { ImageSource(uri: $0, method: nil, headers: nil, body: nil) }
|
|
273
213
|
}
|
|
274
214
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
guard let artwork = track.artwork?.url else { return nil }
|
|
278
|
-
return ImageSource(uri: artwork, method: nil, headers: nil, body: nil)
|
|
215
|
+
guard let artworkConfig = perRouteConfig ?? config.artwork else {
|
|
216
|
+
return artworkUrl.map { ImageSource(uri: $0, method: nil, headers: nil, body: nil) }
|
|
279
217
|
}
|
|
280
218
|
|
|
281
219
|
do {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
method: nil, path: track.artwork?.url, baseUrl: nil, headers: nil,
|
|
289
|
-
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
290
|
-
),
|
|
291
|
-
params: [:],
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
// Artwork config's static fields always apply (not resolve/transform — those
|
|
295
|
-
// run separately), with the per-track resolve merged over them — resolve
|
|
296
|
-
// wins. Matches the web stub and Android (the old either/or skipped static
|
|
297
|
-
// fields whenever a resolver was configured).
|
|
298
|
-
let artworkStaticConfig = RequestConfig(
|
|
299
|
-
method: artworkConfig.method,
|
|
300
|
-
path: artworkConfig.path,
|
|
301
|
-
baseUrl: artworkConfig.baseUrl,
|
|
302
|
-
headers: artworkConfig.headers,
|
|
303
|
-
query: artworkConfig.query,
|
|
304
|
-
body: artworkConfig.body,
|
|
305
|
-
contentType: artworkConfig.contentType,
|
|
306
|
-
userAgent: artworkConfig.userAgent,
|
|
307
|
-
)
|
|
308
|
-
mergedConfig = RequestLayer.merge(mergedConfig, artworkStaticConfig)
|
|
309
|
-
|
|
310
|
-
if artworkConfig.resolve != nil || artworkConfig.resolveSync != nil {
|
|
311
|
-
var asyncResolved: RequestConfig?
|
|
312
|
-
if let resolve = artworkConfig.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
|
|
313
|
-
var syncResolved: RequestConfig?
|
|
314
|
-
if let resolveSync = artworkConfig.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
|
|
315
|
-
if let resolved = RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved) {
|
|
316
|
-
mergedConfig = RequestLayer.merge(mergedConfig, resolved)
|
|
317
|
-
} else if track.artwork == nil {
|
|
318
|
-
// A resolver ran but produced nothing, and there's no artwork URL either
|
|
319
|
-
// → no artwork (matches the web stub).
|
|
320
|
-
return nil
|
|
220
|
+
let base = try await applyLayer(resolvedLayers().request, to: .base(path: artworkUrl), params: [:])
|
|
221
|
+
let resolve: (nonisolated(nonsending)() async throws -> RequestConfig?)? =
|
|
222
|
+
if artworkConfig.resolve != nil || artworkConfig.resolveSync != nil {
|
|
223
|
+
{ try await self.resolved(artworkConfig.resolve, artworkConfig.resolveSync, for: track) }
|
|
224
|
+
} else {
|
|
225
|
+
nil
|
|
321
226
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// Apply image query params if configured and imageContext is provided
|
|
325
|
-
let queryParams = artworkConfig.imageQueryParams
|
|
326
|
-
if let queryParams, let imageContext {
|
|
327
|
-
var contextQuery: [String: String] = [:]
|
|
328
|
-
if let widthKey = queryParams.width, let width = imageContext.width {
|
|
329
|
-
contextQuery[widthKey] = String(Int(width))
|
|
330
|
-
}
|
|
331
|
-
if let heightKey = queryParams.height, let height = imageContext.height {
|
|
332
|
-
contextQuery[heightKey] = String(Int(height))
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if !contextQuery.isEmpty {
|
|
336
|
-
logger.debug("Adding image query params: \(contextQuery)")
|
|
337
|
-
mergedConfig = mergedConfig.copying(query: RequestLayer.mergeDicts(mergedConfig.query, contextQuery))
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Transform (async first, then sync), receiving the image context — which is
|
|
342
|
-
// nil at browse time, matching the web stub and Android. (Previously skipped
|
|
343
|
-
// without a size, leaving browse-time `artworkSource` untransformed for JS
|
|
344
|
-
// consumers; load-time surfaces re-resolve Track-first with the real size,
|
|
345
|
-
// so running it here cannot double-transform.)
|
|
227
|
+
var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
|
|
346
228
|
if let transform = artworkConfig.transform {
|
|
347
|
-
|
|
229
|
+
transforms.append { try await self.awaitAsyncConfig(transform(MediaTransformParams(request: $0, context: imageContext))) }
|
|
348
230
|
}
|
|
349
231
|
if let transformSync = artworkConfig.transformSync {
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Substitute the `{id}` template token with the track's id across path/query/header values.
|
|
354
|
-
// (Configs without the token are unaffected — e.g. browse artwork.) A track without a
|
|
355
|
-
// non-blank id leaves the token LITERALLY in place — the request then visibly 404s, which
|
|
356
|
-
// is intended (self-describing error trail) — with a single warning logged below once the
|
|
357
|
-
// URL is built.
|
|
358
|
-
mergedConfig = substituteTrackId(in: mergedConfig, id: track.id)
|
|
359
|
-
let hasUnfilledIdToken = TrackIdTemplate.containsToken(mergedConfig.path)
|
|
360
|
-
|| TrackIdTemplate.containsToken(mergedConfig.query)
|
|
361
|
-
|| TrackIdTemplate.containsToken(mergedConfig.headers)
|
|
362
|
-
|
|
363
|
-
// Build final URL - if no path after merging, no artwork to transform
|
|
364
|
-
guard mergedConfig.path != nil else {
|
|
365
|
-
return nil
|
|
232
|
+
transforms.append { try await self.awaitSyncConfig(transformSync(MediaTransformParams(request: $0, context: imageContext))) }
|
|
366
233
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
234
|
+
guard let request = try await RequestLayer.artwork(
|
|
235
|
+
RequestConfig(fieldsOf: artworkConfig),
|
|
236
|
+
over: base,
|
|
237
|
+
resolve: resolve,
|
|
238
|
+
trackHasArtwork: track.artwork != nil,
|
|
239
|
+
sizeQuery: RequestLayer.sizeQuery(artworkConfig.imageQueryParams, imageContext),
|
|
240
|
+
transforms: transforms,
|
|
241
|
+
id: track.id,
|
|
242
|
+
) else { return nil }
|
|
243
|
+
|
|
244
|
+
let uri = RequestLayer.buildUrl(request)
|
|
245
|
+
// An unfilled `{id}` stays in the URL, so the request fails visibly.
|
|
246
|
+
if RequestLayer.hasUnfilledIdToken(request) {
|
|
377
247
|
logger.warning("nowPlayingArtwork: track has no id — '{id}' left unfilled in \(uri)")
|
|
378
248
|
}
|
|
379
|
-
|
|
380
|
-
logger.debug("Artwork URL transformed: \(track.artwork?.url ?? "nil") -> \(uri)")
|
|
381
|
-
|
|
249
|
+
logger.debug("Artwork URL transformed: \(artworkUrl ?? "nil") -> \(uri)")
|
|
382
250
|
return ImageSource(
|
|
383
251
|
uri: uri,
|
|
384
|
-
method:
|
|
385
|
-
headers:
|
|
386
|
-
body:
|
|
252
|
+
method: request.method,
|
|
253
|
+
headers: RequestLayer.imageHeaders(request),
|
|
254
|
+
body: request.body,
|
|
387
255
|
)
|
|
388
256
|
} catch {
|
|
389
257
|
logger.error("Failed to transform artwork URL for track: \(track.title), error: \(error.localizedDescription)")
|
|
390
|
-
// On error, return nil to avoid broken images
|
|
391
258
|
return nil
|
|
392
259
|
}
|
|
393
260
|
}
|
|
394
261
|
|
|
395
262
|
// MARK: - Config Utilities
|
|
396
263
|
|
|
397
|
-
/// Replaces the `{id}` token with the track id in a request config's path, query values, and
|
|
398
|
-
/// header values. Used so a `nowPlayingArtwork` like `{ path: "/artwork/{id}" }` resolves.
|
|
399
|
-
/// A nil/blank id leaves the values unchanged (token left literal — see TrackIdTemplate).
|
|
400
|
-
private func substituteTrackId(in config: RequestConfig, id: String?) -> RequestConfig {
|
|
401
|
-
RequestConfig(
|
|
402
|
-
method: config.method,
|
|
403
|
-
path: TrackIdTemplate.substitute(config.path, id: id),
|
|
404
|
-
baseUrl: config.baseUrl,
|
|
405
|
-
headers: TrackIdTemplate.substitute(config.headers, id: id),
|
|
406
|
-
query: TrackIdTemplate.substitute(config.query, id: id),
|
|
407
|
-
body: config.body,
|
|
408
|
-
contentType: config.contentType,
|
|
409
|
-
userAgent: config.userAgent,
|
|
410
|
-
)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
264
|
/// Awaits an **async** config callback and copies the result out of the Nitro
|
|
414
265
|
/// bridge. The callback lowers to `Promise<Promise<RequestConfig>>` (bridge hop →
|
|
415
266
|
/// JS promise), so it is a DOUBLE await. This await depth is the bug-prone part
|
|
@@ -425,23 +276,23 @@ final class RequestPipeline {
|
|
|
425
276
|
try await extractConfig(promise.await())
|
|
426
277
|
}
|
|
427
278
|
|
|
428
|
-
///
|
|
429
|
-
/// memory corruption in Nitro's Swift-C++ bridge when the Promise is deallocated.
|
|
279
|
+
/// A copy of `config`, so nothing reads the Nitro bridge's value after its Promise is gone.
|
|
430
280
|
func extractConfig(_ config: RequestConfig) -> RequestConfig {
|
|
431
|
-
RequestConfig(
|
|
432
|
-
method: config.method,
|
|
433
|
-
path: config.path,
|
|
434
|
-
baseUrl: config.baseUrl,
|
|
435
|
-
headers: config.headers,
|
|
436
|
-
query: config.query,
|
|
437
|
-
body: config.body,
|
|
438
|
-
contentType: config.contentType,
|
|
439
|
-
userAgent: config.userAgent,
|
|
440
|
-
)
|
|
281
|
+
RequestConfig(fieldsOf: config)
|
|
441
282
|
}
|
|
442
283
|
}
|
|
443
284
|
|
|
444
285
|
extension RequestConfig: RequestFields {}
|
|
286
|
+
extension ArtworkRequestConfig: RequestFieldValues {}
|
|
287
|
+
|
|
288
|
+
/// A Request-Config Layer with transforms: the request, browse, media and route configs.
|
|
289
|
+
protocol TransformableLayer: RequestFieldValues where Method == HttpMethod {
|
|
290
|
+
var transform: ((RequestConfig, [String: String]?) -> Promise<Promise<RequestConfig>>)? { get }
|
|
291
|
+
var transformSync: ((RequestConfig, [String: String]?) -> Promise<RequestConfig>)? { get }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
extension TransformableRequestConfig: TransformableLayer {}
|
|
295
|
+
extension MediaRequestConfig: TransformableLayer {}
|
|
445
296
|
|
|
446
297
|
extension RequestPipeline: MediaResolving {
|
|
447
298
|
/// Runs the `nowPlayingArtwork` config for every playing track, id or not: an id-less track
|
|
@@ -47,6 +47,31 @@ enum SectionScope {
|
|
|
47
47
|
}
|
|
48
48
|
return nil
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/// The queue a tap on the contextual `path` plays: the playable tracks of the tapped section,
|
|
52
|
+
/// at the tapped copy when the stamped index still pins it, else at the first identity match;
|
|
53
|
+
/// only that track when `singleTrack`. Nil when the identity is gone from the page or the
|
|
54
|
+
/// tapped track is not playable, so the caller falls back to the stored track.
|
|
55
|
+
static func queue(
|
|
56
|
+
in sections: [Section],
|
|
57
|
+
path: String,
|
|
58
|
+
singleTrack: Bool,
|
|
59
|
+
) -> (tracks: [Track], selectedIndex: Int)? {
|
|
60
|
+
guard let trackId = BrowserPathHelper.extractTrackId(path),
|
|
61
|
+
let scoped = scoped(in: sections, containing: trackId, tappedIndex: BrowserPathHelper.extractIndex(path))
|
|
62
|
+
else { return nil }
|
|
63
|
+
let children = scoped.section.children
|
|
64
|
+
let playable = children.filter(\.isPlayable)
|
|
65
|
+
let selectedIndex: Int
|
|
66
|
+
if let offset = scoped.tappedOffset, children[offset].isPlayable {
|
|
67
|
+
selectedIndex = children.prefix(through: offset).count(where: \.isPlayable) - 1
|
|
68
|
+
} else if let index = playable.firstIndex(where: { $0.identity == trackId }) {
|
|
69
|
+
selectedIndex = index
|
|
70
|
+
} else {
|
|
71
|
+
return nil
|
|
72
|
+
}
|
|
73
|
+
return singleTrack ? ([playable[selectedIndex]], 0) : (playable, selectedIndex)
|
|
74
|
+
}
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
// MARK: - Page shape helpers (ADR 0010)
|