react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34

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 (56) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/Browser/BrowserConfig.swift +0 -5
  4. package/ios/Browser/BrowserError.swift +16 -0
  5. package/ios/Browser/BrowserManager.swift +36 -141
  6. package/ios/Browser/BrowserPathHelper.swift +25 -12
  7. package/ios/Browser/FavoriteSet.swift +46 -0
  8. package/ios/Browser/JsonModels.swift +79 -199
  9. package/ios/Browser/PageResolver.swift +16 -96
  10. package/ios/Browser/RequestLayer.swift +97 -2
  11. package/ios/Browser/RequestPipeline.swift +78 -227
  12. package/ios/Browser/SectionScope.swift +25 -0
  13. package/ios/Browser/SimpleRouter.swift +73 -144
  14. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  15. package/ios/CarPlay/CarPlaySession.swift +3 -3
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  18. package/ios/Extension/SearchParams+Query.swift +28 -0
  19. package/ios/Extension/Track+AVPlayer.swift +0 -16
  20. package/ios/Extension/Track+Copying.swift +3 -1
  21. package/ios/Extension/Track+Identity.swift +7 -0
  22. package/ios/Http/HttpClient.swift +12 -30
  23. package/ios/HybridAudioBrowser+Browser.swift +21 -38
  24. package/ios/HybridAudioBrowser+Options.swift +27 -66
  25. package/ios/HybridAudioBrowser+Player.swift +4 -60
  26. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  27. package/ios/HybridAudioBrowser.swift +36 -99
  28. package/ios/Model/NitroTypeStubs.swift +80 -0
  29. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  30. package/ios/Model/RemoteCommand.swift +6 -78
  31. package/ios/Player/NowPlayingUpdater.swift +13 -7
  32. package/ios/Player/Player.swift +26 -10
  33. package/ios/Player/PlayerCallbacks.swift +1 -4
  34. package/ios/Player/PlayerEngine.swift +17 -0
  35. package/ios/Player/RetryLoop.swift +5 -1
  36. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  37. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  38. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  39. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  40. package/ios/Tests/EmitterTests.swift +3 -48
  41. package/ios/Tests/FavoriteSetTests.swift +60 -0
  42. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  43. package/ios/Tests/PlayerTests.swift +95 -17
  44. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  45. package/ios/Tests/RequestLayerTests.swift +116 -0
  46. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  47. package/ios/Tests/SectionScopeTests.swift +53 -0
  48. package/ios/Tests/SimpleRouterTests.swift +38 -3
  49. package/ios/Tests/Support/TestTypes.swift +44 -3
  50. package/ios/TrackSelector.swift +34 -52
  51. package/ios/Util/Emitter.swift +0 -14
  52. package/ios/Util/NetworkMonitor.swift +8 -25
  53. package/ios/Util/OutputPicker.swift +48 -0
  54. package/package.json +1 -1
  55. package/ios/Model/SourceType.swift +0 -6
  56. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -75,35 +75,17 @@ extension JsonArtwork {
75
75
  /// payload). `init(from:)` therefore never throws, and unknown enum values
76
76
  /// map to nil at `toNitro` time.
77
77
  struct JsonStyle: Codable, Equatable {
78
- let display: String?
79
- let gridWrap: Bool?
80
- let gridTile: String?
81
- let artworkRendering: String?
82
- let imageShape: String?
83
- let accessorySymbol: String?
84
- let cardTint: String?
85
- let cardImage: String?
86
-
87
- init(
88
- display: String? = nil,
89
- gridWrap: Bool? = nil,
90
- gridTile: String? = nil,
91
- artworkRendering: String? = nil,
92
- imageShape: String? = nil,
93
- accessorySymbol: String? = nil,
94
- cardTint: String? = nil,
95
- cardImage: String? = nil,
96
- ) {
97
- self.display = display
98
- self.gridWrap = gridWrap
99
- self.gridTile = gridTile
100
- self.artworkRendering = artworkRendering
101
- self.imageShape = imageShape
102
- self.accessorySymbol = accessorySymbol
103
- self.cardTint = cardTint
104
- self.cardImage = cardImage
105
- }
78
+ var display: String? = nil
79
+ var gridWrap: Bool? = nil
80
+ var gridTile: String? = nil
81
+ var artworkRendering: String? = nil
82
+ var imageShape: String? = nil
83
+ var accessorySymbol: String? = nil
84
+ var cardTint: String? = nil
85
+ var cardImage: String? = nil
86
+ }
106
87
 
88
+ extension JsonStyle {
107
89
  init(from decoder: Decoder) throws {
108
90
  // A non-object value (the retired `style: 'grid'` string) decodes as an
109
91
  // empty block rather than failing the parent.
@@ -132,61 +114,51 @@ extension JsonStyle {
132
114
  /// maps to nil, so "no declaration" has one shape on every platform
133
115
  /// (Android's lenient mapper makes the same collapse).
134
116
  func toTrackStyle() -> TrackStyle? {
135
- let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
136
- let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
137
- let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
138
- // The 'none' sentinel is a legitimate value; only emptiness collapses.
139
- let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
140
- let cardTint = cardTint?.isEmpty == false ? cardTint : nil
141
- let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
142
- guard display != nil || artworkRendering != nil || imageShape != nil
143
- || accessorySymbol != nil || cardTint != nil || cardImage != nil
144
- else { return nil }
145
- return TrackStyle(
146
- display: display,
147
- artworkRendering: artworkRendering,
148
- imageShape: imageShape,
149
- accessorySymbol: accessorySymbol,
150
- cardTint: cardTint,
151
- cardImage: cardImage,
152
- )
117
+ let declarations = trackDeclarations
118
+ return Self.declaresAnything(declarations) ? declarations : nil
153
119
  }
154
120
 
155
121
  func toSectionStyle() -> SectionStyle? {
156
- let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
122
+ let declarations = trackDeclarations
157
123
  let gridTile = gridTile.flatMap { GridTile(fromString: $0.lowercased()) }
158
- let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
159
- let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
160
- let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
161
- let cardTint = cardTint?.isEmpty == false ? cardTint : nil
162
- let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
163
- guard display != nil || artworkRendering != nil || gridWrap != nil
164
- || gridTile != nil || imageShape != nil || accessorySymbol != nil
165
- || cardTint != nil || cardImage != nil
166
- else { return nil }
124
+ guard Self.declaresAnything(declarations) || gridWrap != nil || gridTile != nil else { return nil }
167
125
  // Argument order is the generated init's (own properties before
168
126
  // inherited ones — Nitro flattens `extends` own-props-first).
169
127
  return SectionStyle(
170
128
  gridWrap: gridWrap,
171
129
  gridTile: gridTile,
172
- display: display,
173
- artworkRendering: artworkRendering,
174
- imageShape: imageShape,
175
- accessorySymbol: accessorySymbol,
176
- cardTint: cardTint,
177
- cardImage: cardImage,
130
+ display: declarations.display,
131
+ artworkRendering: declarations.artworkRendering,
132
+ imageShape: declarations.imageShape,
133
+ accessorySymbol: declarations.accessorySymbol,
134
+ cardTint: declarations.cardTint,
135
+ cardImage: declarations.cardImage,
136
+ )
137
+ }
138
+
139
+ /// The declarations a section shares with a track, each nil when absent or unknown.
140
+ private var trackDeclarations: TrackStyle {
141
+ TrackStyle(
142
+ display: display.flatMap { StyleDisplay(fromString: $0.lowercased()) },
143
+ artworkRendering: artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) },
144
+ imageShape: imageShape.flatMap { ImageShape(fromString: $0.lowercased()) },
145
+ // The 'none' sentinel is a legitimate value; only emptiness collapses.
146
+ accessorySymbol: accessorySymbol?.isEmpty == false ? accessorySymbol : nil,
147
+ cardTint: cardTint?.isEmpty == false ? cardTint : nil,
148
+ cardImage: cardImage.flatMap { CardImage(fromString: $0.lowercased()) },
178
149
  )
179
150
  }
180
151
 
152
+ private static func declaresAnything(_ style: TrackStyle) -> Bool {
153
+ style.display != nil || style.artworkRendering != nil || style.imageShape != nil
154
+ || style.accessorySymbol != nil || style.cardTint != nil || style.cardImage != nil
155
+ }
156
+
181
157
  /// Snapshot a live track block back to its JSON model (for persistence).
182
158
  /// An all-nil block snapshots as no block — the decoders collapse it to
183
159
  /// "no declaration" anyway, so never persist the distinction.
184
160
  init?(_ style: TrackStyle?) {
185
- guard let style,
186
- style.display != nil || style.artworkRendering != nil
187
- || style.imageShape != nil || style.accessorySymbol != nil
188
- || style.cardTint != nil || style.cardImage != nil
189
- else { return nil }
161
+ guard let style, Self.declaresAnything(style) else { return nil }
190
162
  self.init(
191
163
  display: Self.string(style.display),
192
164
  artworkRendering: Self.string(style.artworkRendering),
@@ -234,22 +206,10 @@ extension JsonStyle {
234
206
  /// keys in payloads are simply unknown to these models and decode as
235
207
  /// ignored dead weight.
236
208
  struct JsonSection: Codable {
237
- let title: String?
238
- let style: JsonStyle?
239
- let path: String?
240
- let children: [JsonTrack]
241
-
242
- init(
243
- title: String? = nil,
244
- style: JsonStyle? = nil,
245
- path: String? = nil,
246
- children: [JsonTrack],
247
- ) {
248
- self.title = title
249
- self.style = style
250
- self.path = path
251
- self.children = children
252
- }
209
+ var title: String? = nil
210
+ var style: JsonStyle? = nil
211
+ var path: String? = nil
212
+ var children: [JsonTrack]
253
213
  }
254
214
 
255
215
  /// JSON model for per-track HTTP request configuration.
@@ -265,126 +225,46 @@ struct JsonTrackRequest: Codable {
265
225
 
266
226
  /// JSON model for resolved track (container with children).
267
227
  struct JsonResolvedTrack: Codable {
268
- let id: String?
269
- let path: String
270
- let title: String
271
- let subtitle: String?
272
- let artwork: JsonArtwork?
273
- let artist: String?
274
- let albumPath: String?
275
- let album: String?
276
- let description: String?
277
- let genre: String?
278
- let duration: Double?
279
- let sections: [JsonSection]?
280
- let children: [JsonTrack]?
281
- let src: String?
282
- let style: JsonStyle?
283
- let disabled: Bool?
284
- let live: Bool?
285
- let icyCharset: String?
286
- let carPlaySiriListButton: String?
287
-
288
- init(
289
- id: String? = nil,
290
- path: String,
291
- title: String,
292
- subtitle: String? = nil,
293
- artwork: JsonArtwork? = nil,
294
- artist: String? = nil,
295
- albumPath: String? = nil,
296
- album: String? = nil,
297
- description: String? = nil,
298
- genre: String? = nil,
299
- duration: Double? = nil,
300
- sections: [JsonSection]? = nil,
301
- children: [JsonTrack]? = nil,
302
- src: String? = nil,
303
- style: JsonStyle? = nil,
304
- disabled: Bool? = nil,
305
- live: Bool? = nil,
306
- icyCharset: String? = nil,
307
- carPlaySiriListButton: String? = nil,
308
- ) {
309
- self.path = path
310
- self.title = title
311
- self.subtitle = subtitle
312
- self.artwork = artwork
313
- self.artist = artist
314
- self.albumPath = albumPath
315
- self.album = album
316
- self.description = description
317
- self.genre = genre
318
- self.duration = duration
319
- self.sections = sections
320
- self.children = children
321
- self.src = src
322
- self.style = style
323
- self.disabled = disabled
324
- self.live = live
325
- self.icyCharset = icyCharset
326
- self.carPlaySiriListButton = carPlaySiriListButton
327
- self.id = id
328
- }
228
+ var id: String? = nil
229
+ var path: String
230
+ var title: String
231
+ var subtitle: String? = nil
232
+ var artwork: JsonArtwork? = nil
233
+ var artist: String? = nil
234
+ var albumPath: String? = nil
235
+ var album: String? = nil
236
+ var description: String? = nil
237
+ var genre: String? = nil
238
+ var duration: Double? = nil
239
+ var sections: [JsonSection]? = nil
240
+ var children: [JsonTrack]? = nil
241
+ var src: String? = nil
242
+ var style: JsonStyle? = nil
243
+ var disabled: Bool? = nil
244
+ var live: Bool? = nil
245
+ var icyCharset: String? = nil
246
+ var carPlaySiriListButton: String? = nil
329
247
  }
330
248
 
331
249
  /// JSON model for individual tracks.
332
250
  struct JsonTrack: Codable {
333
- let id: String?
334
- let path: String?
335
- let title: String
336
- let subtitle: String?
337
- let artwork: JsonArtwork?
338
- let artist: String?
339
- let albumPath: String?
340
- let album: String?
341
- let description: String?
342
- let genre: String?
343
- let duration: Double?
344
- let src: String?
345
- let request: JsonTrackRequest?
346
- let style: JsonStyle?
347
- let disabled: Bool?
348
- let live: Bool?
349
- let icyCharset: String?
350
-
351
- init(
352
- id: String? = nil,
353
- path: String? = nil,
354
- title: String,
355
- subtitle: String? = nil,
356
- artwork: JsonArtwork? = nil,
357
- artist: String? = nil,
358
- albumPath: String? = nil,
359
- album: String? = nil,
360
- description: String? = nil,
361
- genre: String? = nil,
362
- duration: Double? = nil,
363
- src: String? = nil,
364
- request: JsonTrackRequest? = nil,
365
- style: JsonStyle? = nil,
366
- disabled: Bool? = nil,
367
- live: Bool? = nil,
368
- icyCharset: String? = nil,
369
- ) {
370
- self.path = path
371
- self.title = title
372
- self.subtitle = subtitle
373
- self.artwork = artwork
374
- self.artist = artist
375
- self.albumPath = albumPath
376
- self.album = album
377
- self.description = description
378
- self.genre = genre
379
- self.duration = duration
380
- self.src = src
381
- self.request = request
382
- self.style = style
383
- self.disabled = disabled
384
- self.live = live
385
- self.icyCharset = icyCharset
386
- self.id = id
387
- }
251
+ var id: String? = nil
252
+ var path: String? = nil
253
+ var title: String
254
+ var subtitle: String? = nil
255
+ var artwork: JsonArtwork? = nil
256
+ var artist: String? = nil
257
+ var albumPath: String? = nil
258
+ var album: String? = nil
259
+ var description: String? = nil
260
+ var genre: String? = nil
261
+ var duration: Double? = nil
262
+ var src: String? = nil
263
+ var request: JsonTrackRequest? = nil
264
+ var style: JsonStyle? = nil
265
+ var disabled: Bool? = nil
266
+ var live: Bool? = nil
267
+ var icyCharset: String? = nil
388
268
  }
389
269
 
390
270
  // MARK: - Snapshot live Nitro types back to JSON models (for persistence)
@@ -7,17 +7,7 @@ import os.log
7
7
  /// artwork). Also resolves the tabs and search routes.
8
8
  @MainActor
9
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
10
  let logger = Logger(subsystem: "com.audiobrowser", category: "PageResolver")
20
- private let router = SimpleRouter()
21
11
  private let requests: RequestPipeline
22
12
 
23
13
  private var config: BrowserConfig {
@@ -32,7 +22,7 @@ final class PageResolver {
32
22
  func resolve(_ path: String) async throws -> ResolvedTrack {
33
23
  // Match an explicit route (or the '*' default). With no match, fall back to
34
24
  // the implicit default: fetch the path via the request + browse config.
35
- let routeEntry = (config.routes).flatMap { findBestRouteMatch(path: path, routes: $0) }
25
+ let routeEntry = config.routes.flatMap { SimpleRouter.browseRoute(for: path, in: $0) }
36
26
 
37
27
  let resolvedTrack: ResolvedTrack = if let (entry, routeMatch) = routeEntry {
38
28
  try await resolveRouteEntry(entry, path: path, params: routeMatch.params)
@@ -62,16 +52,16 @@ final class PageResolver {
62
52
 
63
53
  /// The tabs: the `__tabs__` route's page, flattened. Nil when there is no such route.
64
54
  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: [:])
55
+ guard let tabsEntry = config.routes?.first(where: { $0.path == SimpleRouter.tabsRoutePath }) else { return nil }
56
+ let resolved = try await resolveRouteEntry(tabsEntry, path: SimpleRouter.tabsRoutePath, params: [:])
67
57
  // Tabs are a flat list, not a page: a sectioned tabs source flattens.
68
58
  return resolved.flattenedChildren ?? []
69
59
  }
70
60
 
71
61
  /// The `__search__` route's results for `params`, unhydrated.
72
62
  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)
63
+ guard let searchEntry = config.routes?.first(where: { $0.path == SimpleRouter.searchRoutePath }) else {
64
+ throw BrowserError.contentNotFound(path: SimpleRouter.searchRoutePath)
75
65
  }
76
66
  if let callback = searchEntry.searchCallback {
77
67
  return try await callback(params).await().await()
@@ -79,54 +69,11 @@ final class PageResolver {
79
69
  if let searchConfig = searchEntry.searchConfig {
80
70
  return try await requests.fetchSearch(searchConfig, params: params)
81
71
  }
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
72
+ throw BrowserError.contentNotFound(path: SimpleRouter.searchRoutePath)
102
73
  }
103
74
 
104
75
  // MARK: - Route Resolution
105
76
 
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
77
  private func resolveRouteEntry(
131
78
  _ entry: NativeRouteEntry,
132
79
  path: String,
@@ -169,43 +116,17 @@ final class PageResolver {
169
116
 
170
117
  // MARK: - Stamping
171
118
 
119
+ /// `sections` stamped with contextual paths, every track's browse-time artwork (no size
120
+ /// context) resolved at once.
172
121
  private func transformSections(
173
122
  _ sections: [Section],
174
123
  parentPath: String,
175
124
  routeEntry: NativeRouteEntry?,
176
125
  ) 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
126
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: parentPath)
127
+ let artworkConfig = routeEntry?.artwork
128
+ let artworkLoads = stamped.map { section in
129
+ section.children.map { track in
209
130
  Task<ImageSource?, Never> { @MainActor in
210
131
  await self.requests.resolveArtworkUrl(track: track, perRouteConfig: artworkConfig)
211
132
  }
@@ -213,14 +134,13 @@ final class PageResolver {
213
134
  }
214
135
 
215
136
  var transformed: [Section] = []
216
- for sectionIndex in sections.indices {
137
+ for (section, loads) in zip(stamped, artworkLoads) {
217
138
  var children: [Track] = []
218
- for trackIndex in pageTracks[sectionIndex].indices {
219
- let track = pageTracks[sectionIndex][trackIndex]
220
- let artwork = await artworkLoads[sectionIndex][trackIndex].value
139
+ for (track, load) in zip(section.children, loads) {
140
+ let artwork = await load.value
221
141
  children.append(artwork.map { track.copying(artworkSource: $0) } ?? track)
222
142
  }
223
- transformed.append(sections[sectionIndex].copying(children: children))
143
+ transformed.append(section.copying(children: children))
224
144
  }
225
145
  return transformed
226
146
  }
@@ -1,7 +1,10 @@
1
1
  import Foundation
2
+ #if canImport(NitroModules)
3
+ import NitroModules
4
+ #endif
2
5
 
3
- /// A request config's fields: the Nitro `RequestConfig` in the app, a plain struct in tests.
4
- protocol RequestFields {
6
+ /// A request config's fields, as every config kind (request, browse, media, artwork) has them.
7
+ protocol RequestFieldValues<Method> {
5
8
  associatedtype Method
6
9
  var method: Method? { get }
7
10
  var path: String? { get }
@@ -11,7 +14,10 @@ protocol RequestFields {
11
14
  var body: String? { get }
12
15
  var contentType: String? { get }
13
16
  var userAgent: String? { get }
17
+ }
14
18
 
19
+ /// A request config the layers build: the Nitro `RequestConfig` in the app, a plain struct in tests.
20
+ protocol RequestFields: RequestFieldValues {
15
21
  init(
16
22
  method: Method?,
17
23
  path: String?,
@@ -24,6 +30,21 @@ protocol RequestFields {
24
30
  )
25
31
  }
26
32
 
33
+ extension RequestFields {
34
+ /// The config a layer stack starts from: only `path` set.
35
+ static func base(path: String?) -> Self {
36
+ Self(method: nil, path: path, baseUrl: nil, headers: nil, query: nil, body: nil, contentType: nil, userAgent: nil)
37
+ }
38
+
39
+ /// `fields`' values, copied.
40
+ init(fieldsOf fields: some RequestFieldValues<Method>) {
41
+ self.init(
42
+ method: fields.method, path: fields.path, baseUrl: fields.baseUrl, headers: fields.headers,
43
+ query: fields.query, body: fields.body, contentType: fields.contentType, userAgent: fields.userAgent,
44
+ )
45
+ }
46
+ }
47
+
27
48
  /// How Request-Config Layers compose, least specific first, each over the one before. The one
28
49
  /// implementation of these rules; Android and the web stub follow the same ones.
29
50
  enum RequestLayer {
@@ -87,4 +108,78 @@ enum RequestLayer {
87
108
  guard let override else { return base }
88
109
  return base.merging(override) { _, new in new }
89
110
  }
111
+
112
+ // MARK: - Artwork
113
+
114
+ /// An artwork request, in order: `artwork`'s static fields over `base`, path included; the
115
+ /// per-track config `resolve` produces; `sizeQuery`; the transforms, each handed the result of
116
+ /// the one before; then the track's `id` for `{id}`. Nil when `resolve` produces nothing for a
117
+ /// track without artwork, or when no path remains.
118
+ nonisolated(nonsending) static func artwork<C: RequestFields>(
119
+ _ artwork: C,
120
+ over base: C,
121
+ resolve: (nonisolated(nonsending)() async throws -> C?)?,
122
+ trackHasArtwork: Bool,
123
+ sizeQuery: [String: String],
124
+ transforms: [nonisolated(nonsending)(C) async throws -> C],
125
+ id: String?,
126
+ ) async throws -> C? {
127
+ var request = merge(base, artwork)
128
+ if let resolve {
129
+ if let resolved = try await resolve() {
130
+ request = merge(request, resolved)
131
+ } else if !trackHasArtwork {
132
+ return nil
133
+ }
134
+ }
135
+ if !sizeQuery.isEmpty {
136
+ request = merge(request, C(
137
+ method: nil, path: nil, baseUrl: nil, headers: nil,
138
+ query: sizeQuery, body: nil, contentType: nil, userAgent: nil,
139
+ ))
140
+ }
141
+ for transform in transforms {
142
+ request = try await transform(request)
143
+ }
144
+ request = C(
145
+ method: request.method,
146
+ path: TrackIdTemplate.substitute(request.path, id: id),
147
+ baseUrl: request.baseUrl,
148
+ headers: TrackIdTemplate.substitute(request.headers, id: id),
149
+ query: TrackIdTemplate.substitute(request.query, id: id),
150
+ body: request.body,
151
+ contentType: request.contentType,
152
+ userAgent: request.userAgent,
153
+ )
154
+ return request.path == nil ? nil : request
155
+ }
156
+
157
+ /// The image-size query `params` names for `context`'s width and height, in whole pixels.
158
+ static func sizeQuery(_ params: ImageQueryParams?, _ context: ImageContext?) -> [String: String] {
159
+ guard let params, let context else { return [:] }
160
+ var query: [String: String] = [:]
161
+ if let key = params.width, let width = context.width { query[key] = String(Int(width)) }
162
+ if let key = params.height, let height = context.height { query[key] = String(Int(height)) }
163
+ return query
164
+ }
165
+
166
+ /// Whether `request` still carries an `{id}` token: the track had no id to fill it.
167
+ static func hasUnfilledIdToken(_ request: some RequestFieldValues) -> Bool {
168
+ TrackIdTemplate.containsToken(request.path)
169
+ || TrackIdTemplate.containsToken(request.query)
170
+ || TrackIdTemplate.containsToken(request.headers)
171
+ }
172
+
173
+ /// The headers an image load sends: `request.headers`, plus `userAgent` as `User-Agent` and
174
+ /// `contentType` as `Content-Type` unless a header sets them. Nil when there are none.
175
+ static func imageHeaders(_ request: some RequestFieldValues) -> [String: String]? {
176
+ var headers = request.headers ?? [:]
177
+ if let userAgent = request.userAgent, headers["User-Agent"] == nil {
178
+ headers["User-Agent"] = userAgent
179
+ }
180
+ if let contentType = request.contentType, headers["Content-Type"] == nil {
181
+ headers["Content-Type"] = contentType
182
+ }
183
+ return headers.isEmpty ? nil : headers
184
+ }
90
185
  }