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

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.
@@ -111,13 +111,6 @@ extension CPInterfaceController {
111
111
  }
112
112
  }
113
113
 
114
- /// Pops the top template, skipping the raise case (nothing above the root).
115
- func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
116
- whenStackSettled { controller in
117
- controller.guardedPop(animated: animated, completion: completion)
118
- }
119
- }
120
-
121
114
  /// Pops back to `template`, skipping the raise case (not in the hierarchy).
122
115
  /// A no-op success when it is already the top template.
123
116
  func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
@@ -8,12 +8,6 @@ import os.log
8
8
  @MainActor
9
9
  @objc(RNABCarPlayController)
10
10
  public final class RNABCarPlayController: NSObject {
11
- /// The Siri handlers `RNABAudioBrowser.m` looks up by name, referenced so a static link
12
- /// keeps them.
13
- @objc public static let siriIntentHandlerClasses: [AnyClass] = [
14
- RNABMediaIntentHandler.self, RNABMediaAffinityHandler.self, RNABMediaAddHandler.self,
15
- ]
16
-
17
11
  private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayController")
18
12
  private let interfaceController: CPInterfaceController
19
13
  private var interfaceDelegate: InterfaceControllerDelegate?
@@ -6,7 +6,7 @@ import os.log
6
6
  /// Handles all image loading and rendering for CarPlay.
7
7
  ///
8
8
  /// Responsibilities:
9
- /// - SF Symbol rendering (plain and canvas-rendered with light/dark variants)
9
+ /// - SF Symbol rendering, with light/dark variants
10
10
  /// - Artwork loading via BrowserManager's artwork URL resolution
11
11
  /// - Direct artwork loading as fallback
12
12
  /// - Adaptive image tinting for monochrome icons
@@ -25,15 +25,10 @@ final class CarPlayImageLoader {
25
25
 
26
26
  // MARK: - SF Symbols
27
27
 
28
- /// Creates an SF Symbol image for tabs - plain systemName, CarPlay handles tinting
29
- func sfSymbolImage(_ symbolName: String) -> UIImage? {
30
- UIImage(systemName: symbolName)
31
- }
32
-
33
28
  /// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff"). With an
34
29
  /// `fg` it is drawn in that color; without one it is black or white by appearance, over a grey
35
30
  /// tile.
36
- func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
31
+ private func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
37
32
  let (name, background, tint) = SFSymbolRenderer.parseArtwork(artwork)
38
33
  let scale = carTraitCollection.displayScale
39
34
  let lightTile = UIColor(white: 0.92, alpha: 1)
@@ -72,104 +67,56 @@ final class CarPlayImageLoader {
72
67
 
73
68
  // MARK: - Artwork Loading
74
69
 
75
- /// Loads artwork for a track with size context, using the artwork transform if configured.
76
- /// - Parameters:
77
- /// - track: The track to load artwork for
78
- /// - style: The track's resolved style block (inherited values folded in).
79
- /// nil = the track's own declaration — callers without a section context
80
- /// (tabs, now-playing) have no inheritance to resolve.
81
- /// - size: The target size in points (will be multiplied by CarPlay display scale)
82
- /// - completion: Called with the loaded image, or nil on failure
83
- func loadArtwork(for track: Track, style: TrackStyle? = nil, size: CGSize, completion: @escaping @Sendable (UIImage?) -> Void) {
84
- // A track that ships one image per appearance is loaded as two ordinary
85
- // single-URL loads and combined below. Recursing rather than branching
86
- // inside the loader means each variant still goes through the whole
87
- // pipeline — route config, headers, SVG rasterising, the resolve transform
88
- // — instead of a second, thinner fetch path that would drift from it.
70
+ /// A track's artwork at `size` points, through the artwork transform when one is configured;
71
+ /// nil when it has none or the load fails.
72
+ /// - Parameter style: the track's resolved style block; nil for the track's own declaration,
73
+ /// when there is no section to inherit from (tabs, Up Next).
74
+ func artwork(for track: Track, style: TrackStyle? = nil, size: CGSize) async -> UIImage? {
75
+ // Each variant goes through the whole single-URL path (route config, headers, SVG, the
76
+ // resolve transform), so the pair cannot drift from it.
89
77
  if let variants = track.artwork?.variants {
90
- loadVariants(for: track, style: style, variants: variants, size: size, completion: completion)
91
- return
78
+ return await artwork(for: track, style: style, variants: variants, size: size)
92
79
  }
93
80
 
94
81
  let artwork = track.artwork?.url
95
- Task {
96
- var resolved: ArtworkResolvedImage?
97
- if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager = self.browserManager {
98
- let scale = Double(self.carTraitCollection.displayScale)
99
- let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
100
- resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
101
- .map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
102
- }
103
- let action = CarPlayArtworkResolver.action(
104
- artwork: artwork,
105
- artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
106
- resolved: resolved,
107
- stencil: (style ?? track.style)?.artworkRendering == .stencil,
108
- width: Double(size.width),
109
- height: Double(size.height),
110
- )
82
+ var resolved: ArtworkResolvedImage?
83
+ if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager {
84
+ let scale = Double(carTraitCollection.displayScale)
85
+ let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
86
+ resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
87
+ .map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
88
+ }
89
+ let action = CarPlayArtworkResolver.action(
90
+ artwork: artwork,
91
+ artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
92
+ resolved: resolved,
93
+ stencil: (style ?? track.style)?.artworkRendering == .stencil,
94
+ width: Double(size.width),
95
+ height: Double(size.height),
96
+ )
111
97
 
112
- switch action {
113
- case let .sfSymbol(artwork, width, height):
114
- completion(self.sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height)))
115
- case let .fetch(uri, headers, shouldTint, isSvg):
116
- let image = await self.fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
117
- if let image, shouldTint {
118
- completion(self.createAdaptiveImage(image, carTraitCollection: self.carTraitCollection))
119
- } else {
120
- completion(image)
121
- }
122
- case .none:
123
- completion(nil)
124
- }
98
+ switch action {
99
+ case let .sfSymbol(artwork, width, height):
100
+ return sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height))
101
+ case let .fetch(uri, headers, shouldTint, isSvg):
102
+ let image = await fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
103
+ guard let image, shouldTint else { return image }
104
+ return tinted(image)
105
+ case .none:
106
+ return nil
125
107
  }
126
108
  }
127
109
 
128
110
  // MARK: - Private
129
111
 
130
- /// Loads both images of an `ArtworkVariants` pair and returns them as one
131
- /// adaptive image.
132
- ///
133
- /// The pair is registered on a `UIImageAsset`, which is what makes an
134
- /// appearance change mid-drive re-resolve in place: CarPlay re-reads the
135
- /// asset against the new trait collection, so the row swaps image without
136
- /// another fetch or a re-query of the browse tree.
137
- ///
138
- /// If either half fails to load, the other is used alone rather than dropping
139
- /// the row's image entirely — a glyph that is wrong for one appearance still
140
- /// beats an empty slot, and this is usually a transient fetch failure.
141
- private func loadVariants(
142
- for track: Track,
143
- style: TrackStyle?,
144
- variants: ArtworkVariants,
145
- size: CGSize,
146
- completion: @escaping @Sendable (UIImage?) -> Void,
147
- ) {
148
- Task {
149
- async let light = self.loadArtworkAsync(
150
- for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size,
151
- )
152
- async let dark = self.loadArtworkAsync(
153
- for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size,
154
- )
155
- let (lightImage, darkImage) = await (light, dark)
156
-
157
- guard let lightImage, let darkImage else {
158
- completion(lightImage ?? darkImage)
159
- return
160
- }
161
-
162
- completion(.adaptive(light: lightImage, dark: darkImage, for: self.carTraitCollection))
163
- }
164
- }
165
-
166
- /// `loadArtwork` as an awaitable, so the two variants can load concurrently.
167
- private func loadArtworkAsync(for track: Track, style: TrackStyle?, size: CGSize) async -> UIImage? {
168
- await withCheckedContinuation { continuation in
169
- self.loadArtwork(for: track, style: style, size: size) { image in
170
- continuation.resume(returning: image)
171
- }
172
- }
112
+ /// Both images of an `ArtworkVariants` pair as one adaptive image, which CarPlay re-resolves
113
+ /// in place when the appearance changes. When one half fails, the other is used alone.
114
+ private func artwork(for track: Track, style: TrackStyle?, variants: ArtworkVariants, size: CGSize) async -> UIImage? {
115
+ async let light = artwork(for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size)
116
+ async let dark = artwork(for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size)
117
+ let (lightImage, darkImage) = await (light, dark)
118
+ guard let lightImage, let darkImage else { return lightImage ?? darkImage }
119
+ return .adaptive(light: lightImage, dark: darkImage, for: carTraitCollection)
173
120
  }
174
121
 
175
122
  /// Fetches an image from a URL with optional headers and SVG processing.
@@ -199,8 +146,8 @@ final class CarPlayImageLoader {
199
146
  return rendered.withRenderingMode(.alwaysOriginal)
200
147
  }
201
148
 
202
- /// Creates light/dark tinted variants of an image and returns the appropriate one for current appearance
203
- private nonisolated func createAdaptiveImage(_ image: UIImage, carTraitCollection: UITraitCollection) -> UIImage {
149
+ /// A monochrome image drawn black for light appearance and white for dark.
150
+ private func tinted(_ image: UIImage) -> UIImage {
204
151
  .adaptive(
205
152
  light: renderImageToBitmap(image, tintColor: .black),
206
153
  dark: renderImageToBitmap(image, tintColor: .white),
@@ -29,18 +29,42 @@ extension CPListItem {
29
29
  final class CarPlayListItemFactory {
30
30
  private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayListItemFactory")
31
31
 
32
- var imageLoader: CarPlayImageLoader?
33
- /// Pushed by the controller (at creation and when options change), like
34
- /// `imageLoader` — the factory never reads options itself.
35
- var playingIndicatorLocation: CPListItemPlayingIndicatorLocation = .leading
32
+ private let imageLoader: CarPlayImageLoader
33
+ /// The last-applied location; the factory never reads options itself.
34
+ var playingIndicatorLocation: CPListItemPlayingIndicatorLocation
36
35
  private let isActiveTrack: (_ identity: String?) -> Bool
36
+ /// Runs an artwork load as a task the session owns, so ending the session cancels it.
37
+ private let spawn: (@escaping @MainActor () async -> Void) -> Void
37
38
  /// A row or tile was tapped; call `completion` once CarPlay may take the next tap.
38
- var onItemSelected: (Track, @escaping () -> Void) -> Void = { _, completion in completion() }
39
+ private let onItemSelected: (Track, @escaping () -> Void) -> Void
39
40
  /// A section header's "view all" was tapped.
40
- var onNavigate: (_ path: String, _ title: String) -> Void = { _, _ in }
41
-
42
- init(isActiveTrack: @escaping (_ identity: String?) -> Bool) {
41
+ private let onNavigate: (_ path: String, _ title: String) -> Void
42
+
43
+ init(
44
+ imageLoader: CarPlayImageLoader,
45
+ playingIndicatorLocation: CPListItemPlayingIndicatorLocation,
46
+ isActiveTrack: @escaping (_ identity: String?) -> Bool,
47
+ spawn: @escaping (@escaping @MainActor () async -> Void) -> Void,
48
+ onItemSelected: @escaping (Track, @escaping () -> Void) -> Void,
49
+ onNavigate: @escaping (_ path: String, _ title: String) -> Void,
50
+ ) {
51
+ self.imageLoader = imageLoader
52
+ self.playingIndicatorLocation = playingIndicatorLocation
43
53
  self.isActiveTrack = isActiveTrack
54
+ self.spawn = spawn
55
+ self.onItemSelected = onItemSelected
56
+ self.onNavigate = onNavigate
57
+ }
58
+
59
+ /// Loads `track`'s artwork and hands it to `apply` unless the session ended meanwhile.
60
+ private func loadArtwork(
61
+ for track: Track, style: TrackStyle?, size: CGSize, apply: @escaping @MainActor (UIImage?) -> Void,
62
+ ) {
63
+ spawn { [imageLoader] in
64
+ let image = await imageLoader.artwork(for: track, style: style, size: size)
65
+ guard !Task.isCancelled else { return }
66
+ apply(image)
67
+ }
44
68
  }
45
69
 
46
70
  // MARK: - Sections
@@ -117,14 +141,11 @@ final class CarPlayListItemFactory {
117
141
 
118
142
  // MARK: - List Item
119
143
 
120
- /// Creates a CPListItem for a track with common setup (typed userInfo, artwork, isPlaying).
144
+ /// A list row for `track`: its identity, accessory, playing flag, artwork and tap handler.
121
145
  /// - Parameters:
122
- /// - track: The track to create the item for
123
- /// - style: The track's resolved style block (`track ?? section ?? page`,
124
- /// via `StyleResolver`). nil = the track's own declaration — the one
125
- /// genuinely section-less caller (the Up Next queue list) has no
126
- /// inheritance to resolve.
127
- /// - handler: Optional custom handler. If nil, uses default browse/play handling.
146
+ /// - style: The track's resolved style block (`track ?? section ?? page`); nil for the
147
+ /// track's own declaration, when there is no section (Up Next).
148
+ /// - handler: Replaces the default browse or play on tap.
128
149
  func createListItem(
129
150
  for track: Track,
130
151
  style: TrackStyle? = nil,
@@ -135,26 +156,18 @@ final class CarPlayListItemFactory {
135
156
  detailText: track.subtitle,
136
157
  )
137
158
 
138
- // Store typed info for updatePlayingIndicators()
139
159
  item.setCarPlayItemInfo(CarPlayItemInfo(identity: track.identity))
140
160
 
141
- // Set accessory type based on whether track is browsable or playable
142
161
  if let src = track.src {
143
- // Playable track - check if it's currently playing
144
162
  item.accessoryType = .none
145
- // Consumer-configurable via `ios.carPlayPlayingIndicatorLocation`
146
- // (default leading: the indicator draws in the artwork slot). Note the
147
- // indicator's rendering is owned by the phone's CarPlay service, and on
148
- // some iOS/CarPlay-Simulator combinations it isn't drawn at all — for
149
- // ANY third-party app. Verify on a real head unit before assuming a
150
- // logic bug here.
163
+ // The phone's CarPlay service draws the indicator, and some iOS and CarPlay Simulator
164
+ // combinations draw none for any app: check a real head unit before suspecting this code.
151
165
  item.playingIndicatorLocation = playingIndicatorLocation
152
166
  item.isPlaying = isActiveTrack(track.identity)
153
167
  if item.isPlaying {
154
168
  logger.debug("Setting isPlaying=true for: \(track.title) (src: \(src))")
155
169
  }
156
170
  } else if track.path != nil {
157
- // Browsable only - show disclosure indicator
158
171
  item.accessoryType = .disclosureIndicator
159
172
  }
160
173
 
@@ -176,21 +189,15 @@ final class CarPlayListItemFactory {
176
189
  item.isEnabled = false
177
190
  }
178
191
 
179
- // Load artwork with size context for proper CDN optimization
180
192
  if track.artwork != nil || track.artworkSource != nil {
181
- // Set empty placeholder to reserve space while loading
182
- item.setImage(imageLoader?.placeholderImage(size: CPListItem.maximumImageSize))
183
- imageLoader?.loadArtwork(for: track, style: style, size: CPListItem.maximumImageSize) { [weak item] image in
184
- Task { @MainActor in
185
- item?.setImage(image)
186
- }
193
+ item.setImage(imageLoader.placeholderImage(size: CPListItem.maximumImageSize))
194
+ loadArtwork(for: track, style: style, size: CPListItem.maximumImageSize) { [weak item] image in
195
+ item?.setImage(image)
187
196
  }
188
197
  }
189
198
 
190
- // Set selection handler. The disabled guard wraps BOTH handler paths —
191
- // custom handlers (Up Next rows skipping the queue, say) included: belt
192
- // over the isEnabled braces, so an unavailable track stays inert even if
193
- // a surface delivers the tap.
199
+ // The disabled guard wraps custom handlers too (Up Next rows), so an unavailable track
200
+ // stays inert even if a surface delivers the tap.
194
201
  let selection = handler ?? { [onItemSelected] _, completion in
195
202
  onItemSelected(track, completion)
196
203
  }
@@ -268,50 +275,40 @@ final class CarPlayListItemFactory {
268
275
  ? Array(visibleChildren.prefix(CPMaximumNumberOfGridImages))
269
276
  : visibleChildren
270
277
  let placeholder = { [imageLoader] in
271
- imageLoader?.placeholderImage(size: Self.rowImageSize) ?? UIImage()
278
+ imageLoader.placeholderImage(size: Self.rowImageSize) ?? UIImage()
272
279
  }
273
280
 
274
281
  let item: CPListImageRowItem
275
- // Applies an async-loaded artwork image to a tile on the element-based
276
- // API (the elements are captured per availability branch — their type
277
- // doesn't exist before iOS 26). nil = the legacy gridImages path.
278
- // @MainActor makes the closure Sendable for the artwork completion hop.
279
- var applyImage: (@MainActor @Sendable (Int, UIImage) -> Void)?
280
- // Resolved once per track, shared by element construction and the
281
- // artwork loads below.
282
+ // Sets a loaded image on its tile's element; nil on the legacy gridImages path, which has
283
+ // no elements (their types do not exist before iOS 26).
284
+ var applyImage: (@MainActor (Int, UIImage) -> Void)?
282
285
  let resolvedStyles = tracks.map { StyleResolver.trackStyle(track: $0.style, section: style) }
283
286
  if #available(iOS 26.0, *) {
284
- // Builds a family's elements with the shared per-element tail: the
285
- // disabled fact (isEnabled lives on the element base class) applied
286
- // once, not per family.
287
- func makeElements<Element: CPListImageRowItemElement>(
288
- _ make: (Track, TrackStyle) -> Element,
289
- ) -> [Element] {
290
- zip(tracks, resolvedStyles).map { track, resolved in
287
+ // One element per track, grayed when disabled, built into the row by the family's
288
+ // initializer; each tile's image lands on its element.
289
+ func row<Element: CPListImageRowItemElement>(
290
+ _ build: (String?, [Element], Bool) -> CPListImageRowItem,
291
+ element make: (Track, TrackStyle) -> Element,
292
+ ) -> CPListImageRowItem {
293
+ let elements = zip(tracks, resolvedStyles).map { track, resolved in
291
294
  let element = make(track, resolved)
292
295
  if track.disabled == true {
293
296
  element.isEnabled = false
294
297
  }
295
298
  return element
296
299
  }
297
- }
298
- // The image setter every element family shares (`image` is on the base
299
- // class); bounds-checked against the elements the row was built with.
300
- func imageApplier(_ elements: [some CPListImageRowItemElement]) -> @MainActor @Sendable (Int, UIImage) -> Void {
301
- { index, image in
300
+ applyImage = { index, image in
302
301
  guard index < elements.count else { return }
303
302
  elements[index].image = image
304
303
  }
304
+ return build(rowText, elements, !singleLine)
305
305
  }
306
- // The family is style-driven (`SectionPresentation.tileFamily`); every
307
- // family takes either wrap mode. Pre-26 the treatment drops and the
308
- // layout survives (the legacy branch below).
306
+ // Pre-26 the family's treatment drops and the layout survives (the legacy branch below).
309
307
  switch family {
310
308
  case .cardElements:
311
- // Cards have no shape and no accessory slot; their knobs are the
312
- // tint and the image mode ('background' fills the card full-height
313
- // and fades the tint in behind the labels).
314
- let elements = makeElements { track, resolved in
309
+ // Cards have no shape and no accessory slot; 'background' fills the card full-height
310
+ // and fades the tint in behind the labels.
311
+ item = row(CPListImageRowItem.init(text:cardElements:allowsMultipleLines:)) { track, resolved in
315
312
  CPListImageRowItemCardElement(
316
313
  image: placeholder(),
317
314
  showsImageFullHeight: resolved.cardImage == .background,
@@ -320,12 +317,8 @@ final class CarPlayListItemFactory {
320
317
  tintColor: resolved.cardTint.flatMap { UIColor(declaredHex: $0) },
321
318
  )
322
319
  }
323
- item = CPListImageRowItem(
324
- text: rowText, cardElements: elements, allowsMultipleLines: !singleLine,
325
- )
326
- applyImage = imageApplier(elements)
327
320
  case .condensedElements:
328
- let elements = makeElements { track, resolved in
321
+ item = row(CPListImageRowItem.init(text:condensedElements:allowsMultipleLines:)) { track, resolved in
329
322
  CPListImageRowItemCondensedElement(
330
323
  image: placeholder(),
331
324
  imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
@@ -334,14 +327,10 @@ final class CarPlayListItemFactory {
334
327
  accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
335
328
  )
336
329
  }
337
- item = CPListImageRowItem(
338
- text: rowText, condensedElements: elements, allowsMultipleLines: !singleLine,
339
- )
340
- applyImage = imageApplier(elements)
341
330
  case .imageGridElements:
342
331
  // imageGridElements, never the title-less gridElements: an
343
332
  // artwork-less track must keep its name on screen (ADR 0010).
344
- let elements = makeElements { track, resolved in
333
+ item = row(CPListImageRowItem.init(text:imageGridElements:allowsMultipleLines:)) { track, resolved in
345
334
  CPListImageRowItemImageGridElement(
346
335
  image: placeholder(),
347
336
  imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
@@ -349,22 +338,14 @@ final class CarPlayListItemFactory {
349
338
  accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
350
339
  )
351
340
  }
352
- item = CPListImageRowItem(
353
- text: rowText, imageGridElements: elements, allowsMultipleLines: !singleLine,
354
- )
355
- applyImage = imageApplier(elements)
356
341
  case .rowElements:
357
342
  // Titled, so an artwork-less track keeps its name on screen
358
343
  // (ADR 0010); no shape and no accessory slot.
359
- let elements = makeElements { track, _ in
344
+ item = row(CPListImageRowItem.init(text:elements:allowsMultipleLines:)) { track, _ in
360
345
  CPListImageRowItemRowElement(
361
346
  image: placeholder(), title: track.title, subtitle: track.subtitle,
362
347
  )
363
348
  }
364
- item = CPListImageRowItem(
365
- text: rowText, elements: elements, allowsMultipleLines: !singleLine,
366
- )
367
- applyImage = imageApplier(elements)
368
349
  }
369
350
  } else {
370
351
  let placeholders = tracks.map { _ in placeholder() }
@@ -386,9 +367,8 @@ final class CarPlayListItemFactory {
386
367
  }
387
368
  }
388
369
 
389
- // Per-tile taps select the child track directly. A disabled tile is
390
- // grayed and inert (26+ elements) — the guard keeps it inert even if
391
- // the surface delivers the tap anyway.
370
+ // A disabled tile is grayed and inert (iOS 26 elements); the guard keeps it inert even if
371
+ // the surface delivers the tap.
392
372
  item.listImageRowHandler = { [onItemSelected] _, index, completion in
393
373
  guard index < tracks.count, tracks[index].disabled != true else {
394
374
  completion()
@@ -397,28 +377,23 @@ final class CarPlayListItemFactory {
397
377
  onItemSelected(tracks[index], completion)
398
378
  }
399
379
 
400
- // Load artwork for each rendered tile asynchronously
401
380
  for (index, track) in tracks.enumerated() {
402
381
  guard track.artwork != nil || track.artworkSource != nil else { continue }
403
382
 
404
383
  let resolved = resolvedStyles[index]
405
- imageLoader?.loadArtwork(
384
+ loadArtwork(
406
385
  for: track,
407
386
  style: resolved,
408
387
  size: Self.tileImageSize(family: family, cardImage: resolved.cardImage),
409
388
  ) { [weak item, applyImage] image in
410
- Task { @MainActor in
411
- guard let item, let image else { return }
412
- if let applyImage {
413
- // The element-based API exposes a settable `image`; assigning it
414
- // updates the element the row observes.
415
- applyImage(index, image)
416
- } else {
417
- var images = item.gridImages
418
- if index < images.count {
419
- images[index] = image
420
- item.update(images)
421
- }
389
+ guard let item, let image else { return }
390
+ if let applyImage {
391
+ applyImage(index, image)
392
+ } else {
393
+ var images = item.gridImages
394
+ if index < images.count {
395
+ images[index] = image
396
+ item.update(images)
422
397
  }
423
398
  }
424
399
  }
@@ -19,20 +19,12 @@ final class CarPlayNowPlayingManager {
19
19
  private var subscriptions: [Subscription] = []
20
20
  private var nowPlayingObserver: NowPlayingObserver?
21
21
  private weak var upNextTemplate: CPListTemplate?
22
- /// Everything the built button row renders — button types, the heart's
23
- /// favorited state, and the shuffle/repeat `isSelected` state. Rebuilds are
24
- /// skipped while this is unchanged (`configChanged` fires on unrelated
25
- /// churn, and every `updateNowPlayingButtons` call re-renders ALL buttons).
22
+ /// What the built button row renders. A rebuild is skipped while it is unchanged: every
23
+ /// `updateNowPlayingButtons` re-renders all the buttons.
26
24
  ///
27
- /// shuffle/repeat selected state is part of the snapshot because CarPlay
28
- /// SERIALIZES the buttons (NSSecureCoding) to its UI process: whenever the
29
- /// now-playing screen re-renders (it does on every metadata change), the
30
- /// stateful buttons first draw from their serialized `isSelected`, and only
31
- /// then overlay the live state the app reports via the command center. A
32
- /// button built with the default `isSelected = false` while repeat/shuffle
33
- /// is on therefore flashes background-off → background-on at every
34
- /// re-render. Stamping `isSelected` at build time (and rebuilding when the
35
- /// mode changes) makes the first-frame render match the live state.
25
+ /// Shuffle and repeat carry their `isSelected` because CarPlay serializes the buttons to its
26
+ /// UI process and draws each re-render from the serialized state before the live one: a
27
+ /// button built unselected while its mode is on flashes off, then on.
36
28
  private struct BuiltButtonRow: Equatable {
37
29
  let types: [CarPlayNowPlayingButton]
38
30
  let favorited: Bool?
@@ -131,16 +123,9 @@ final class CarPlayNowPlayingManager {
131
123
  private func refreshButtons() {
132
124
  // A gate hides the custom buttons; the transport controls stay, as a gate never blocks
133
125
  // playback.
134
- let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons
135
- // The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
136
- // nothing about overflow — unlike lists (trim) or the tab bar (throw). The
137
- // config comes straight from JS, where duplicates and excess are
138
- // representable, so dedupe (order preserved) and clamp here.
139
- var deduped: [CarPlayNowPlayingButton] = []
140
- for button in configured where !deduped.contains(button) {
141
- deduped.append(button)
142
- }
143
- let buttons = Array(deduped.prefix(5))
126
+ let buttons = NowPlayingButtonRules.buttons(
127
+ audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons,
128
+ )
144
129
  let row = BuiltButtonRow(
145
130
  types: buttons,
146
131
  favorited: buttons.contains(.favorite) ? audioBrowser.isActiveTrackFavorited() : nil,
@@ -296,15 +281,7 @@ final class CarPlayNowPlayingManager {
296
281
  private func handleRepeatButtonTapped() {
297
282
  guard let player else { return }
298
283
  let currentMode = player.repeatMode
299
- let newMode: RepeatMode = switch currentMode {
300
- case .off:
301
- .track
302
- case .track:
303
- .queue
304
- case .queue:
305
- .off
306
- }
307
-
284
+ let newMode = NowPlayingButtonRules.repeatMode(after: currentMode)
308
285
  player.repeatMode = newMode
309
286
  logger.info("CarPlay repeat mode changed: \(currentMode.stringValue) → \(newMode.stringValue)")
310
287
  }
@@ -319,19 +296,9 @@ final class CarPlayNowPlayingManager {
319
296
  }
320
297
 
321
298
  private func handlePlaybackRateButtonTapped() {
322
- let rates = audioBrowser.playerOptions.playbackRates
323
- guard !rates.isEmpty, let player else { return }
324
-
299
+ guard let player else { return }
325
300
  let currentRate = Double(player.rate)
326
- let nextRate: Double
327
-
328
- if let currentIndex = rates.firstIndex(where: { (currentRate - $0).magnitude < 0.01 }) {
329
- let nextIndex = (currentIndex + 1) % rates.count
330
- nextRate = rates[nextIndex]
331
- } else {
332
- nextRate = rates.first { $0 > currentRate } ?? rates[0]
333
- }
334
-
301
+ guard let nextRate = NowPlayingButtonRules.rate(after: currentRate, in: audioBrowser.playerOptions.playbackRates) else { return }
335
302
  player.rate = Float(nextRate)
336
303
  logger.info("CarPlay playback rate changed: \(currentRate) → \(nextRate)")
337
304
  }
@@ -22,7 +22,7 @@ extension CarPlaySession {
22
22
  // A playable track always lands on Now Playing, so it is pushed now with the tapped
23
23
  // track's fields: the queue expansion and media resolve may both hit the network.
24
24
  if track.src != nil {
25
- audioBrowser.engine?.loadNowPlayingMetadata(for: track)
25
+ player?.previewNowPlaying(track)
26
26
  nowPlaying.prepare(for: track)
27
27
  nowPlaying.showNowPlaying()
28
28
  }
@@ -32,7 +32,7 @@ extension CarPlaySession {
32
32
  guard let player = session.player else { return }
33
33
  let result = await session.trackSelector.select(track: track, player: player)
34
34
  guard !Task.isCancelled else {
35
- if track.src != nil { session.restoreNowPlaying() }
35
+ if track.src != nil { session.player?.endNowPlayingPreview() }
36
36
  return
37
37
  }
38
38
  switch result {
@@ -49,17 +49,6 @@ extension CarPlaySession {
49
49
  }
50
50
  }
51
51
 
52
- /// Puts the current track back on the now-playing surface, over a tapped one that won't play.
53
- private func restoreNowPlaying() {
54
- guard let engine = audioBrowser.engine else { return }
55
- if let current = player?.currentTrack {
56
- engine.loadNowPlayingMetadata(for: current)
57
- engine.nowPlayingUpdater.render()
58
- } else {
59
- engine.clearNowPlaying()
60
- }
61
- }
62
-
63
52
  /// Pushes `path`'s page as an empty, spinning template that fills in once it appears; or the
64
53
  /// Browse Gate's page when the gate gates it; or selects the tab that is that path.
65
54
  func navigate(to path: String, title: String) async {