react-native-audio-browser 0.2.0-next.32 → 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.
@@ -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 `clearContentCache` while they run resolves them
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 transforms
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: TransformableRequestConfig?,
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
- let fields = RequestConfig(
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?.stringValue ?? "GET",
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: PageResolver.searchQueryParams(params),
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
- /// Resolves a media URL using the configured media transform.
152
- /// Returns the transformed URL, headers, and user-agent for playback.
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
- logger.debug("Resolving media URL: \(originalUrl)")
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
- baseRequest = try await applyLayer(
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 = baseRequest
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 media kind's Request-Config Layer over `base`: its transforms, when it has any, else
215
- /// its static fields (`RequestLayer.apply`).
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 = media.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
190
+ if let resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
239
191
  var syncResolved: RequestConfig?
240
- if let resolveSync = media.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
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
- /// Resolves an artwork URL for a track using the configured artwork transform.
247
- /// Returns an ImageSource with transformed URL and headers for image loading.
248
- ///
249
- /// - Parameters:
250
- /// - track: The track whose artwork URL should be transformed
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
- if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
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
- if let uri = SFSymbolRenderer.shared.render(artwork, canvasSize: canvasSize) {
262
- return ImageSource(uri: uri, method: nil, headers: nil, body: nil)
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
- // If no artwork config, return original artwork URL as simple ImageSource
276
- guard let artworkConfig = effectiveArtworkConfig else {
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
- // Base config via the shared `request` layer (its transform runs for
283
- // artwork too), with track.artwork as the default path. Artwork then
284
- // shapes further via its own resolve / static fields / transform.
285
- var mergedConfig = try await applyLayer(
286
- resolvedLayers().request,
287
- to: RequestConfig(
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
- mergedConfig = try await awaitAsyncConfig(transform(MediaTransformParams(request: mergedConfig, context: imageContext)))
229
+ transforms.append { try await self.awaitAsyncConfig(transform(MediaTransformParams(request: $0, context: imageContext))) }
348
230
  }
349
231
  if let transformSync = artworkConfig.transformSync {
350
- mergedConfig = try await awaitSyncConfig(transformSync(MediaTransformParams(request: mergedConfig, context: imageContext)))
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
- let uri = RequestLayer.buildUrl(mergedConfig)
369
-
370
- // Build headers map, merging explicit headers with userAgent
371
- var headers = mergedConfig.headers ?? [:]
372
- if let userAgent = mergedConfig.userAgent, headers["User-Agent"] == nil {
373
- headers["User-Agent"] = userAgent
374
- }
375
-
376
- if hasUnfilledIdToken {
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: mergedConfig.method,
385
- headers: headers.isEmpty ? nil : headers,
386
- body: mergedConfig.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
- /// Extracts all values from a RequestConfig into a new instance to avoid
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)