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.
- package/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- 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 +79 -199
- 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/CarPlay/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- 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 +21 -38
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +80 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +26 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/RequestLayerTests.swift +116 -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/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/Model/SourceType.swift +0 -6
- 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
136
|
-
|
|
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
|
|
122
|
+
let declarations = trackDeclarations
|
|
157
123
|
let gridTile = gridTile.flatMap { GridTile(fromString: $0.lowercased()) }
|
|
158
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 =
|
|
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 ==
|
|
66
|
-
let resolved = try await resolveRouteEntry(tabsEntry, path:
|
|
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 ==
|
|
74
|
-
throw BrowserError.contentNotFound(path:
|
|
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:
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
137
|
+
for (section, loads) in zip(stamped, artworkLoads) {
|
|
217
138
|
var children: [Track] = []
|
|
218
|
-
for
|
|
219
|
-
let
|
|
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(
|
|
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
|
|
4
|
-
protocol
|
|
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
|
}
|