react-native-audio-browser 0.2.0-next.33 → 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.
Files changed (55) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
  4. package/ios/CarPlay/CarPlayController.swift +0 -6
  5. package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
  6. package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
  7. package/ios/CarPlay/CarPlayNowPlayingManager.swift +13 -46
  8. package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
  9. package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
  10. package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
  11. package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
  12. package/ios/CarPlay/CarPlaySession.swift +10 -12
  13. package/ios/CarPlay/IntentHandlers.swift +96 -0
  14. package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
  15. package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
  18. package/ios/CarPlay/RNABAudioBrowser.m +7 -15
  19. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  20. package/ios/Extension/Track+AVPlayer.swift +0 -16
  21. package/ios/HybridAudioBrowser+Browser.swift +16 -17
  22. package/ios/HybridAudioBrowser+Options.swift +27 -66
  23. package/ios/HybridAudioBrowser+Player.swift +4 -60
  24. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  25. package/ios/HybridAudioBrowser.swift +36 -99
  26. package/ios/Model/NitroTypeStubs.swift +15 -0
  27. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  28. package/ios/Model/RemoteCommand.swift +6 -78
  29. package/ios/Player/NowPlayingUpdater.swift +13 -7
  30. package/ios/Player/Player.swift +45 -10
  31. package/ios/Player/PlayerCallbacks.swift +1 -4
  32. package/ios/Player/PlayerEngine.swift +17 -0
  33. package/ios/Player/RetryLoop.swift +5 -1
  34. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  35. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  36. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  37. package/ios/Tests/ConcurrentMapTests.swift +42 -0
  38. package/ios/Tests/EmitterTests.swift +3 -48
  39. package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
  40. package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
  41. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  42. package/ios/Tests/PlayerTests.swift +136 -17
  43. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  44. package/ios/Tests/Support/TestTypes.swift +44 -3
  45. package/ios/TrackSelector.swift +34 -52
  46. package/ios/Util/ConcurrentMap.swift +19 -0
  47. package/ios/Util/Emitter.swift +0 -14
  48. package/ios/Util/NetworkMonitor.swift +8 -25
  49. package/ios/Util/OutputPicker.swift +48 -0
  50. package/package.json +1 -1
  51. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
  52. package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
  53. package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
  54. package/ios/Model/SourceType.swift +0 -6
  55. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -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
  }
@@ -11,7 +11,7 @@ final class CarPlayNowPlayingManager {
11
11
  private let interfaceController: CPInterfaceController
12
12
  private let audioBrowser: HybridAudioBrowser
13
13
  /// Read on each use: a re-setup replaces the bridge's player.
14
- private var player: Player? { audioBrowser.getPlayer() }
14
+ private var player: Player? { audioBrowser.player }
15
15
  private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
16
16
  /// Pushes a browse destination: the album line's.
17
17
  private let navigate: (_ path: String, _ title: String) -> Void
@@ -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.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.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
  }
@@ -349,7 +316,7 @@ final class CarPlayNowPlayingManager {
349
316
  }
350
317
 
351
318
  private func updateUpNextButton() {
352
- CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
319
+ CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.playerOptions.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
353
320
  }
354
321
 
355
322
  fileprivate func handleUpNextButtonTapped() {
@@ -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 {
@@ -26,19 +26,18 @@ extension CarPlaySession {
26
26
  NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
27
27
  )
28
28
  }
29
- // Cancelled as soon as the resolve settles: formatting its error is a JS round trip the
30
- // timeout must not overwrite.
31
- defer { watchdog.cancel() }
32
-
29
+ let outcome: Result<ResolvedTrack, Error>
33
30
  do {
34
- let resolved: ResolvedTrack
35
- do {
36
- resolved = try await audioBrowser.browserManager.resolve(path, useCache: true)
37
- } catch {
38
- watchdog.cancel()
39
- throw error
40
- }
41
- watchdog.cancel()
31
+ outcome = try await .success(audioBrowser.browserManager.resolve(path, useCache: true))
32
+ } catch {
33
+ outcome = .failure(error)
34
+ }
35
+ // As soon as the resolve settles: formatting its error is a JS round trip the timeout must
36
+ // not overwrite.
37
+ watchdog.cancel()
38
+
39
+ switch outcome {
40
+ case let .success(resolved):
42
41
  guard !Task.isCancelled else { return }
43
42
  // An assistant cell is content in itself, even with no rows; a page of empty sections is
44
43
  // empty (ADR 0001).
@@ -48,7 +47,7 @@ extension CarPlaySession {
48
47
  } else {
49
48
  await showNavigationError(.code(.emptyContent), path: path, on: template)
50
49
  }
51
- } catch {
50
+ case let .failure(error):
52
51
  logger.error("Failed to load content for \(path): \(error.localizedDescription)")
53
52
  await showNavigationError(NavigationError.from(error), path: path, on: template)
54
53
  }
@@ -23,21 +23,21 @@ extension CarPlaySession {
23
23
  /// invalidated (e.g. a locale change).
24
24
  func refreshAllDisplayedTemplates() async {
25
25
  let browserManager = audioBrowser.browserManager
26
- let loads = displayedListTemplates().compactMap { template in
27
- template.contentPath.map { path in
28
- (template, Task<ResolvedTrack?, Never> { @MainActor [logger] in
29
- do {
30
- return try await browserManager.resolve(path, useCache: false)
31
- } catch {
32
- logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
33
- return nil
34
- }
35
- })
26
+ let targets = displayedListTemplates().compactMap { template in template.contentPath.map { (template, $0) } }
27
+ let pages = await targets.map(\.1).concurrentMap { [logger] path -> ResolvedTrack? in
28
+ do {
29
+ return try await browserManager.resolve(path, useCache: false)
30
+ } catch {
31
+ if !(error is CancellationError || Task.isCancelled) {
32
+ logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
33
+ }
34
+ return nil
36
35
  }
37
36
  }
38
- for load in loads {
39
- guard let resolved = await load.1.value, !Task.isCancelled else { continue }
40
- updateTemplate(load.0, with: resolved)
37
+ guard !Task.isCancelled else { return }
38
+ for ((template, _), page) in zip(targets, pages) {
39
+ guard let page else { continue }
40
+ updateTemplate(template, with: page)
41
41
  }
42
42
  }
43
43
  }
@@ -39,9 +39,9 @@ extension CarPlaySession {
39
39
  // A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
40
40
  // otherwise it is the empty-content error, like any other failure (ADR 0001).
41
41
  guard !tabs.isEmpty else {
42
- if gated, let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: .browse, path: nil, search: nil)) {
42
+ if gated, let chrome = await browseGates(for: [nil])[0] {
43
43
  guard isCurrent(build) else { return }
44
- audioBrowser.onGate(GateEvent(reason: .browse))
44
+ reportGates([chrome], reason: .browse)
45
45
  interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
46
46
  } else {
47
47
  await showRootError(.code(.emptyContent), build: build)
@@ -49,11 +49,9 @@ extension CarPlaySession {
49
49
  return
50
50
  }
51
51
 
52
- let chromes = gated ? await gates(for: tabs) : tabs.map { _ in nil }
52
+ let chromes = gated ? await browseGates(for: tabs.map(\.path)) : tabs.map { _ in nil }
53
53
  guard isCurrent(build) else { return }
54
- for _ in chromes.compactMap(\.self) {
55
- audioBrowser.onGate(GateEvent(reason: .browse))
56
- }
54
+ reportGates(chromes, reason: .browse)
57
55
  let templates = zip(tabs, chromes).map { tab, chrome in
58
56
  chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
59
57
  }
@@ -123,24 +121,24 @@ extension CarPlaySession {
123
121
  /// request is allowed.
124
122
  func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
125
123
  let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
126
- if chrome != nil { audioBrowser.onGate(GateEvent(reason: reason)) }
124
+ reportGates([chrome], reason: reason)
127
125
  return chrome
128
126
  }
129
127
 
130
- /// One decision per tab, made concurrently and returned in tab order. Reports nothing: the
131
- /// caller fires `onGate` once it knows its build is still current.
132
- private func gates(for tabs: [Track]) async -> [Gate?] {
128
+ /// One browse decision per path, made concurrently and returned in path order. Reports
129
+ /// nothing: the caller reports once it knows its build is still current.
130
+ private func browseGates(for paths: [String?]) async -> [Gate?] {
133
131
  let browseGate = audioBrowser.browseGate
134
- let decisions = tabs.map { tab in
135
- Task<Gate?, Never> { @MainActor in
136
- await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
137
- }
132
+ return await paths.concurrentMap { path in
133
+ await browseGate.decide(NativeGateRequest(reason: .browse, path: path, search: nil))
138
134
  }
139
- var chromes: [Gate?] = []
140
- for decision in decisions {
141
- await chromes.append(decision.value)
135
+ }
136
+
137
+ /// Fires `onGate` once for each request the gate gated.
138
+ private func reportGates(_ chromes: [Gate?], reason: GateReason) {
139
+ for _ in chromes.compactMap(\.self) {
140
+ audioBrowser.onGate(GateEvent(reason: reason))
142
141
  }
143
- return chromes
144
142
  }
145
143
 
146
144
  /// A tab's content shell, which loads when it first appears.
@@ -168,15 +166,16 @@ extension CarPlaySession {
168
166
  func applyTabBarEntry(to template: CPTemplate, for track: Track) {
169
167
  template.tabTitle = track.title
170
168
  if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
169
+ // A plain symbol: CarPlay tints the tab image itself.
171
170
  let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
172
- if let image = imageLoader.sfSymbolImage(symbolName) {
171
+ if let image = UIImage(systemName: symbolName) {
173
172
  template.tabImage = image
174
173
  }
175
174
  } else if track.artwork != nil || track.artworkSource != nil {
176
- imageLoader.loadArtwork(for: track, size: CGSize(width: 24, height: 24)) { [weak template] image in
177
- Task { @MainActor in
178
- if let image { template?.tabImage = image }
179
- }
175
+ spawn { [weak template] session in
176
+ let image = await session.imageLoader.artwork(for: track, size: CGSize(width: 24, height: 24))
177
+ guard !Task.isCancelled, let image else { return }
178
+ template?.tabImage = image
180
179
  }
181
180
  } else {
182
181
  // A restamp must not leave an old icon on a now title-only tab.
@@ -12,10 +12,17 @@ final class CarPlaySession {
12
12
  let interfaceController: CPInterfaceController
13
13
  let audioBrowser: HybridAudioBrowser
14
14
  /// Read on each use: a re-setup replaces the bridge's player.
15
- var player: Player? { audioBrowser.getPlayer() }
15
+ var player: Player? { audioBrowser.player }
16
16
  let trackSelector: TrackSelector
17
17
  let imageLoader: CarPlayImageLoader
18
- let listItemFactory: CarPlayListItemFactory
18
+ private(set) lazy var listItemFactory = CarPlayListItemFactory(
19
+ imageLoader: imageLoader,
20
+ playingIndicatorLocation: playingIndicatorLocation,
21
+ isActiveTrack: { [weak self] identity in identity != nil && self?.player?.currentTrack?.identity == identity },
22
+ spawn: { [weak self] work in self?.spawn { _ in await work() } },
23
+ onItemSelected: { [weak self] track, completion in self?.select(track, completion: completion) },
24
+ onNavigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
25
+ )
19
26
  private(set) var nowPlaying: CarPlayNowPlayingManager!
20
27
 
21
28
  private var subscriptions: [Subscription] = []
@@ -56,12 +63,6 @@ final class CarPlaySession {
56
63
  carTraitCollection: interfaceController.carTraitCollection,
57
64
  browserManager: audioBrowser.browserManager,
58
65
  )
59
- listItemFactory = CarPlayListItemFactory(
60
- isActiveTrack: { [weak audioBrowser] identity in
61
- identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
62
- },
63
- )
64
- listItemFactory.imageLoader = imageLoader
65
66
  nowPlaying = CarPlayNowPlayingManager(
66
67
  interfaceController: interfaceController,
67
68
  audioBrowser: audioBrowser,
@@ -70,9 +71,6 @@ final class CarPlaySession {
70
71
  },
71
72
  navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
72
73
  )
73
- listItemFactory.onItemSelected = { [weak self] track, completion in self?.select(track, completion: completion) }
74
- listItemFactory.onNavigate = { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } }
75
- listItemFactory.playingIndicatorLocation = playingIndicatorLocation
76
74
  }
77
75
 
78
76
  func start() {
@@ -156,7 +154,7 @@ final class CarPlaySession {
156
154
  // MARK: - Playing Indicators
157
155
 
158
156
  var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
159
- switch audioBrowser.carPlayPlayingIndicatorLocation {
157
+ switch audioBrowser.playerOptions.carPlayPlayingIndicatorLocation {
160
158
  case .trailing: .trailing
161
159
  case .leading: .leading
162
160
  }