react-native-audio-browser 0.2.0-next.28 → 0.2.0-next.29

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 (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -30,10 +30,29 @@ final class CarPlayImageLoader {
30
30
  UIImage(systemName: symbolName)
31
31
  }
32
32
 
33
- /// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff").
33
+ /// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff"). With an
34
+ /// `fg` it is drawn in that color; without one it is black or white by appearance, over a grey
35
+ /// tile.
34
36
  func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
35
- let (symbolName, bg, fg) = SFSymbolRenderer.parseArtwork(artwork)
36
- return sfSymbolImageForSize(symbolName, canvasSize: canvasSize, backgroundColor: bg, symbolColor: fg)
37
+ let (name, background, tint) = SFSymbolRenderer.parseArtwork(artwork)
38
+ let scale = carTraitCollection.displayScale
39
+ let lightTile = UIColor(white: 0.92, alpha: 1)
40
+ let darkTile = UIColor(white: 0.15, alpha: 1)
41
+ if let tint {
42
+ return SFSymbolRenderer.image(
43
+ name: name, canvasSize: canvasSize, scale: scale, tint: tint,
44
+ background: background ?? (tint == .white ? darkTile : lightTile),
45
+ )
46
+ }
47
+ guard
48
+ let light = SFSymbolRenderer.image(
49
+ name: name, canvasSize: canvasSize, scale: scale, tint: .black, background: lightTile,
50
+ ),
51
+ let dark = SFSymbolRenderer.image(
52
+ name: name, canvasSize: canvasSize, scale: scale, tint: .white, background: darkTile,
53
+ )
54
+ else { return nil }
55
+ return .adaptive(light: light, dark: dark, for: carTraitCollection)
37
56
  }
38
57
 
39
58
  /// Cached empty placeholders, keyed by point size — list items and image-row
@@ -72,27 +91,22 @@ final class CarPlayImageLoader {
72
91
  return
73
92
  }
74
93
 
75
- // The annotation goes on the closure's return type, not the binding:
76
- // @Sendable isn't inferred through `map`, and omitting it fails with
77
- // "type of expression is ambiguous" — which doesn't mention @Sendable.
78
- let urlResolver = browserManager.map { bm -> @Sendable (Double, Double) async -> ArtworkResolvedImage? in
79
- { [weak bm] pixelWidth, pixelHeight in
80
- guard let bm else { return nil }
81
- let imageContext = ImageContext(width: pixelWidth, height: pixelHeight)
82
- guard let source = await bm.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: imageContext) else { return nil }
83
- return ArtworkResolvedImage(uri: source.uri, headers: source.headers)
84
- }
85
- }
86
-
94
+ let artwork = track.artwork?.url
87
95
  Task {
88
- let action = await CarPlayArtworkResolver.resolve(
89
- artwork: track.artwork?.url,
90
- artworkSourceUri: track.artworkSource?.uri,
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,
91
107
  stencil: (style ?? track.style)?.artworkRendering == .stencil,
92
- targetWidth: Double(size.width),
93
- targetHeight: Double(size.height),
94
- displayScale: Double(self.carTraitCollection.displayScale),
95
- urlResolver: urlResolver,
108
+ width: Double(size.width),
109
+ height: Double(size.height),
96
110
  )
97
111
 
98
112
  switch action {
@@ -145,10 +159,7 @@ final class CarPlayImageLoader {
145
159
  return
146
160
  }
147
161
 
148
- let asset = UIImageAsset()
149
- asset.register(lightImage, with: UITraitCollection(userInterfaceStyle: .light))
150
- asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
151
- completion(asset.image(with: self.carTraitCollection))
162
+ completion(.adaptive(light: lightImage, dark: darkImage, for: self.carTraitCollection))
152
163
  }
153
164
  }
154
165
 
@@ -174,55 +185,6 @@ final class CarPlayImageLoader {
174
185
  return await ArtworkImageFetcher.fetchImage(from: source, downsampleTo: (size, scale))
175
186
  }
176
187
 
177
- /// Creates an SF Symbol image rendered at the given canvas size.
178
- /// When explicit colors are provided, uses them directly.
179
- /// Otherwise falls back to automatic light/dark variants.
180
- private func sfSymbolImageForSize(_ symbolName: String, canvasSize: CGSize, backgroundColor: UIColor? = nil, symbolColor: UIColor? = nil) -> UIImage? {
181
- let scale = carTraitCollection.displayScale
182
- let symbolPointSize = min(canvasSize.width, canvasSize.height) * 0.45
183
-
184
- let config = UIImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .medium)
185
- guard let symbol = UIImage(systemName: symbolName, withConfiguration: config) else { return nil }
186
-
187
- // Explicit colors: single image, no light/dark variants needed
188
- if let symbolColor {
189
- return renderSymbolInCanvas(symbol, tintColor: symbolColor, backgroundColor: backgroundColor, canvasSize: canvasSize, scale: scale)
190
- }
191
-
192
- // Auto light/dark variants
193
- let lightImage = renderSymbolInCanvas(symbol, tintColor: .black, backgroundColor: nil, canvasSize: canvasSize, scale: scale)
194
- let darkImage = renderSymbolInCanvas(symbol, tintColor: .white, backgroundColor: nil, canvasSize: canvasSize, scale: scale)
195
-
196
- let asset = UIImageAsset()
197
- asset.register(lightImage, with: UITraitCollection(userInterfaceStyle: .light))
198
- asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
199
-
200
- return asset.image(with: carTraitCollection)
201
- }
202
-
203
- /// Renders an SF Symbol centered in a canvas with a background fill.
204
- /// When `backgroundColor` is nil, derives a default from the tint color (light/dark).
205
- private nonisolated func renderSymbolInCanvas(_ symbol: UIImage, tintColor: UIColor, backgroundColor: UIColor?, canvasSize: CGSize, scale: CGFloat) -> UIImage {
206
- let renderer = UIGraphicsImageRenderer(size: canvasSize, format: {
207
- let fmt = UIGraphicsImageRendererFormat()
208
- fmt.scale = scale
209
- return fmt
210
- }())
211
-
212
- return renderer.image { _ in
213
- let bgColor = backgroundColor ?? (tintColor == .white ? UIColor(white: 0.15, alpha: 1) : UIColor(white: 0.92, alpha: 1))
214
- bgColor.setFill()
215
- UIRectFill(CGRect(origin: .zero, size: canvasSize))
216
-
217
- let symbolSize = symbol.size
218
- let x = (canvasSize.width - symbolSize.width) / 2
219
- let y = (canvasSize.height - symbolSize.height) / 2
220
-
221
- tintColor.set()
222
- symbol.withRenderingMode(.alwaysTemplate).draw(in: CGRect(x: x, y: y, width: symbolSize.width, height: symbolSize.height))
223
- }.withRenderingMode(.alwaysOriginal)
224
- }
225
-
226
188
  /// Renders an image to a bitmap with the specified tint color (for monochrome icons).
227
189
  private nonisolated func renderImageToBitmap(_ image: UIImage, tintColor: UIColor) -> UIImage {
228
190
  UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
@@ -239,13 +201,10 @@ final class CarPlayImageLoader {
239
201
 
240
202
  /// Creates light/dark tinted variants of an image and returns the appropriate one for current appearance
241
203
  private nonisolated func createAdaptiveImage(_ image: UIImage, carTraitCollection: UITraitCollection) -> UIImage {
242
- let lightImage = renderImageToBitmap(image, tintColor: .black)
243
- let darkImage = renderImageToBitmap(image, tintColor: .white)
244
-
245
- let asset = UIImageAsset()
246
- asset.register(lightImage, with: UITraitCollection(userInterfaceStyle: .light))
247
- asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
248
-
249
- return asset.image(with: carTraitCollection)
204
+ .adaptive(
205
+ light: renderImageToBitmap(image, tintColor: .black),
206
+ dark: renderImageToBitmap(image, tintColor: .white),
207
+ for: carTraitCollection,
208
+ )
250
209
  }
251
210
  }
@@ -34,14 +34,13 @@ final class CarPlayListItemFactory {
34
34
  /// `imageLoader` — the factory never reads options itself.
35
35
  var playingIndicatorLocation: CPListItemPlayingIndicatorLocation = .leading
36
36
  private let isActiveTrack: (_ identity: String?) -> Bool
37
- private let onItemSelected: (Track, @escaping () -> Void) -> Void
37
+ /// 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
+ /// A section header's "view all" was tapped.
40
+ var onNavigate: (_ path: String, _ title: String) -> Void = { _, _ in }
38
41
 
39
- init(
40
- isActiveTrack: @escaping (_ identity: String?) -> Bool,
41
- onItemSelected: @escaping (Track, @escaping () -> Void) -> Void,
42
- ) {
42
+ init(isActiveTrack: @escaping (_ identity: String?) -> Bool) {
43
43
  self.isActiveTrack = isActiveTrack
44
- self.onItemSelected = onItemSelected
45
44
  }
46
45
 
47
46
  // MARK: - Sections
@@ -380,9 +379,10 @@ final class CarPlayListItemFactory {
380
379
  // Header tap → navigate to section.path ("view all"). A path-less
381
380
  // section is a pure preview and gets no handler.
382
381
  if let path = section.path {
383
- let target = Self.navigationTrack(path: path, title: section.title)
384
- item.handler = { [onItemSelected] _, completion in
385
- onItemSelected(target, completion)
382
+ let title = section.title ?? ""
383
+ item.handler = { [onNavigate] _, completion in
384
+ onNavigate(path, title)
385
+ completion()
386
386
  }
387
387
  }
388
388
 
@@ -426,18 +426,4 @@ final class CarPlayListItemFactory {
426
426
 
427
427
  return item
428
428
  }
429
-
430
- /// A synthetic browsable Track for section-header navigation — the
431
- /// section's "view all" surface has a path and a title, but no Track.
432
- /// Style-less by design: CarPlay resolves presentation from the page it
433
- /// navigates to. (Android's counterpart projects the section's block —
434
- /// the promise only exists to feed Android Auto's parent-level hint.)
435
- private static func navigationTrack(path: String, title: String?) -> Track {
436
- Track(
437
- id: nil, path: path, src: nil, artwork: nil, artworkSource: nil,
438
- request: nil, title: title ?? "", subtitle: nil,
439
- artist: nil, albumPath: nil, album: nil, description: nil, genre: nil,
440
- duration: nil, style: nil, disabled: nil, favorited: nil, live: nil, icyCharset: nil,
441
- )
442
- }
443
429
  }
@@ -2,29 +2,26 @@ import CarPlay
2
2
  import Foundation
3
3
  import os.log
4
4
 
5
- /// Manages the Now Playing template, buttons, Up Next, and related state for CarPlay.
6
- ///
7
- /// Responsibilities:
8
- /// - CPNowPlayingTemplate setup and button configuration
9
- /// - Button action handlers (shuffle, repeat, favorite, playback rate)
10
- /// - Button state updates (favorite appearance, Up Next enabled)
11
- /// - Up Next template creation and queue change handling
12
- /// - NowPlayingObserver for Up Next button taps
5
+ /// The CarPlay Now Playing template for one session: its custom buttons, the album line's
6
+ /// destination, Up Next, and the events that refresh them.
13
7
  @MainActor
14
8
  final class CarPlayNowPlayingManager {
15
9
  private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayNowPlayingManager")
16
10
 
17
11
  private let interfaceController: CPInterfaceController
18
- var listItemFactory: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
19
- /// Pushes a browse destination (wired to the controller's navigateToPath).
20
- var navigateToPath: ((_ path: String, _ title: String) -> Void)?
21
-
22
- private weak var audioBrowser: HybridAudioBrowser?
12
+ private let audioBrowser: HybridAudioBrowser
13
+ /// Read on each use: a re-setup replaces the bridge's player.
14
+ private var player: Player? { audioBrowser.getPlayer() }
15
+ private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
16
+ /// Pushes a browse destination: the album line's.
17
+ private let navigate: (_ path: String, _ title: String) -> Void
18
+
19
+ private var subscriptions: [Subscription] = []
23
20
  private var nowPlayingObserver: NowPlayingObserver?
24
21
  private weak var upNextTemplate: CPListTemplate?
25
22
  /// Everything the built button row renders — button types, the heart's
26
23
  /// favorited state, and the shuffle/repeat `isSelected` state. Rebuilds are
27
- /// skipped while this is unchanged (`onConfigChanged` fires on unrelated
24
+ /// skipped while this is unchanged (`configChanged` fires on unrelated
28
25
  /// churn, and every `updateNowPlayingButtons` call re-renders ALL buttons).
29
26
  ///
30
27
  /// shuffle/repeat selected state is part of the snapshot because CarPlay
@@ -60,57 +57,81 @@ final class CarPlayNowPlayingManager {
60
57
  /// from the tapped track at push time. Cleared when the active track changes.
61
58
  private var pendingAlbumArtistTrack: Track?
62
59
 
63
- /// Convenience accessor for browser config
64
60
  private var config: BrowserConfig {
65
- audioBrowser?.browserManager.config ?? BrowserConfig()
66
- }
67
-
68
- /// The active track's favorited state, from the authoritative favorite set
69
- /// (see `HybridAudioBrowser.isActiveTrackFavorited`).
70
- private var isActiveTrackFavorited: Bool {
71
- audioBrowser?.isActiveTrackFavorited() ?? false
61
+ audioBrowser.browserManager.config
72
62
  }
73
63
 
74
- init(interfaceController: CPInterfaceController) {
64
+ init(
65
+ interfaceController: CPInterfaceController,
66
+ audioBrowser: HybridAudioBrowser,
67
+ listItem: @escaping (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem,
68
+ navigate: @escaping (_ path: String, _ title: String) -> Void,
69
+ ) {
75
70
  self.interfaceController = interfaceController
76
- self.listItemFactory = { track, _ in CPListItem(text: track.title, detailText: nil) }
71
+ self.audioBrowser = audioBrowser
72
+ self.listItem = listItem
73
+ self.navigate = navigate
77
74
  }
78
75
 
79
76
  // MARK: - Lifecycle
80
77
 
81
- func setup(audioBrowser: HybridAudioBrowser) {
82
- self.audioBrowser = audioBrowser
83
- setupNowPlayingTemplate()
78
+ func start() {
79
+ let observer = NowPlayingObserver(manager: self)
80
+ nowPlayingObserver = observer
81
+ CPNowPlayingTemplate.shared.add(observer)
82
+
83
+ let browserManager = audioBrowser.browserManager
84
+ subscriptions = [
85
+ browserManager.configChanged.subscribeOnMain { [weak self] _ in self?.refresh() },
86
+ audioBrowser.playerOptionsChangedEmitter.subscribeOnMain { [weak self] in self?.refresh() },
87
+ browserManager.favoritesChanged.subscribeOnMain { [weak self] in self?.refreshButtons() },
88
+ audioBrowser.favoriteChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
89
+ audioBrowser.repeatModeChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
90
+ audioBrowser.shuffleChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
91
+ audioBrowser.queueChangedEmitter.subscribeOnMain { [weak self] in self?.queueChanged($0) },
92
+ audioBrowser.showNowPlayingRequestedEmitter.subscribeOnMain { [weak self] in self?.showNowPlaying() },
93
+ audioBrowser.activeTrackChangedEmitter.subscribeOnMain { [weak self] _ in
94
+ // The player's current track is authoritative again.
95
+ self?.pendingAlbumArtistTrack = nil
96
+ self?.refresh()
97
+ },
98
+ ]
99
+ refresh()
84
100
  }
85
101
 
86
- func teardown() {
87
- if let observer = nowPlayingObserver {
88
- CPNowPlayingTemplate.shared.remove(observer)
89
- nowPlayingObserver = nil
90
- }
102
+ func end() {
103
+ if let nowPlayingObserver { CPNowPlayingTemplate.shared.remove(nowPlayingObserver) }
104
+ nowPlayingObserver = nil
105
+ subscriptions = []
91
106
  pendingAlbumArtistTrack = nil
92
- audioBrowser = nil
107
+ // A resolve still in flight must not set the shared template for the next session.
108
+ albumArtistGeneration &+= 1
93
109
  }
94
110
 
95
- // MARK: - Now Playing Template
96
-
97
- private func setupNowPlayingTemplate() {
98
- let template = CPNowPlayingTemplate.shared
99
-
100
- let observer = NowPlayingObserver(manager: self)
101
- nowPlayingObserver = observer
102
- template.add(observer)
111
+ /// Re-renders Up Next availability, the custom buttons and the album line's destination.
112
+ func refresh() {
113
+ updateUpNextButton()
114
+ refreshButtons()
115
+ updateAlbumArtistDestination()
116
+ }
103
117
 
104
- setupNowPlayingButtons()
105
- updateNowPlayingButtonStates()
118
+ /// Marks `track` as the just-tapped (still loading) track and derives the album line's
119
+ /// destination from it. Call before pushing Now Playing: CarPlay reads
120
+ /// `isAlbumArtistButtonEnabled` at display time, so enabling it after the push renders no
121
+ /// chevron.
122
+ func prepare(for track: Track) {
123
+ pendingAlbumArtistTrack = track
124
+ updateAlbumArtistDestination()
106
125
  }
107
126
 
108
- /// Sets up custom Now Playing buttons based on configuration and current
109
- /// player state, skipping the rebuild when nothing the row renders changed.
110
- func setupNowPlayingButtons() {
111
- // While a gate is active, custom buttons (favorite etc.) are hidden;
112
- // the system transport controls stay — a gate never blocks playback.
113
- let configured = (audioBrowser?.isGateActive ?? false) ? [] : (audioBrowser?.carPlayNowPlayingButtons ?? [])
127
+ // MARK: - Buttons
128
+
129
+ /// Builds the custom button row from the configuration and player state, skipping the
130
+ /// rebuild when nothing the row renders changed.
131
+ private func refreshButtons() {
132
+ // A gate hides the custom buttons; the transport controls stay, as a gate never blocks
133
+ // playback.
134
+ let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.carPlayNowPlayingButtons
114
135
  // The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
115
136
  // nothing about overflow — unlike lists (trim) or the tab bar (throw). The
116
137
  // config comes straight from JS, where duplicates and excess are
@@ -120,12 +141,11 @@ final class CarPlayNowPlayingManager {
120
141
  deduped.append(button)
121
142
  }
122
143
  let buttons = Array(deduped.prefix(5))
123
- let player = audioBrowser?.getPlayer()
124
144
  let row = BuiltButtonRow(
125
145
  types: buttons,
126
- favorited: buttons.contains(.favorite) ? isActiveTrackFavorited : nil,
127
- shuffleSelected: buttons.contains(.shuffle) ? (player?.shuffleEnabled ?? false) : nil,
128
- repeatSelected: buttons.contains(.repeat) ? (player?.getRepeatMode() ?? .off) != .off : nil,
146
+ favorited: buttons.contains(.favorite) ? audioBrowser.isActiveTrackFavorited() : nil,
147
+ shuffleSelected: buttons.contains(.shuffle) ? player?.shuffleEnabled ?? false : nil,
148
+ repeatSelected: buttons.contains(.repeat) ? (player?.repeatMode ?? .off) != .off : nil,
129
149
  )
130
150
  guard row != builtRow else { return }
131
151
  builtRow = row
@@ -182,10 +202,10 @@ final class CarPlayNowPlayingManager {
182
202
  /// stack but buried under templates pushed on top of it (Up Next, the album
183
203
  /// line's browse destination): a user-initiated surface (tap, voice play)
184
204
  /// pops back to it, while the deferred post-resolve calls in
185
- /// `handleItemSelection` pass `false` — they run seconds after the tap, and
205
+ /// `CarPlaySession.select` pass `false` — they run seconds after the tap, and
186
206
  /// popping then would yank away a screen the user navigated to meanwhile.
187
207
  func showNowPlaying(popToFront: Bool = true) {
188
- updateNowPlayingButtonStates()
208
+ refresh()
189
209
  let nowPlayingTemplate = CPNowPlayingTemplate.shared
190
210
  if interfaceController.templates.contains(where: { $0 === nowPlayingTemplate }) {
191
211
  if popToFront {
@@ -196,48 +216,18 @@ final class CarPlayNowPlayingManager {
196
216
  }
197
217
  }
198
218
 
199
- // MARK: - Button State Updates
200
-
201
- /// Refreshes the row for a favorite/shuffle/repeat state change — the
202
- /// snapshot diff in setupNowPlayingButtons makes this a no-op when nothing
203
- /// the row renders actually changed.
204
- func updateFavoriteButtonState() {
205
- setupNowPlayingButtons()
206
- }
207
-
208
- /// Updates Now Playing button states based on config and current queue
209
- func updateNowPlayingButtonStates() {
210
- updateNowPlayingUpNextButton()
211
- setupNowPlayingButtons()
212
- updateAlbumArtistButtonState()
213
- }
214
-
215
- /// Re-resolves the album line's destination for the active track and
216
- /// enables the (tappable) album/artist button only when one exists:
217
- /// `track.albumPath`, or the app's `resolveAlbumPath` result.
218
- /// Marks `track` as the just-tapped (still loading) track and derives the
219
- /// album line's destination from it. Must run before the Now Playing
220
- /// template is pushed — CarPlay reads `isAlbumArtistButtonEnabled` at display
221
- /// time, so enabling after the push (when the load lands) renders no chevron.
222
- func prepareAlbumArtistButton(for track: Track) {
223
- pendingAlbumArtistTrack = track
224
- updateAlbumArtistButtonState()
225
- }
226
-
227
- /// Clears the tap-time pending track (the player's `currentTrack` is now
228
- /// authoritative) and refreshes all button states.
229
- func handleActiveTrackChanged() {
230
- pendingAlbumArtistTrack = nil
231
- updateNowPlayingButtonStates()
232
- }
219
+ // MARK: - Album Line
233
220
 
234
- func updateAlbumArtistButtonState() {
221
+ /// Resolves the album line's destination for the active (or just-tapped) track and enables
222
+ /// the album/artist button only when one exists: `track.albumPath`, or the app's
223
+ /// `resolveAlbumPath` result.
224
+ private func updateAlbumArtistDestination() {
235
225
  albumArtistGeneration &+= 1
236
226
  let generation = albumArtistGeneration
237
227
 
238
228
  // The just-tapped track wins while its load is in flight: currentTrack
239
229
  // still points at the previous track (or nil) at push time.
240
- guard let track = pendingAlbumArtistTrack ?? audioBrowser?.getPlayer()?.currentTrack else {
230
+ guard let track = pendingAlbumArtistTrack ?? player?.currentTrack else {
241
231
  logger.debug("AlbumArtist: no current track → disabled")
242
232
  setAlbumArtistDestination(nil)
243
233
  return
@@ -276,18 +266,10 @@ final class CarPlayNowPlayingManager {
276
266
 
277
267
  // MARK: - Queue Changes
278
268
 
279
- /// Handles queue changes - updates Up Next list if visible
280
- func handleQueueChanged(_ tracks: [Track]) {
281
- updateNowPlayingUpNextButton()
282
-
283
- guard let template = upNextTemplate,
284
- let player = audioBrowser?.getPlayer()
285
- else {
286
- return
287
- }
288
-
289
- logger.debug("Queue changed, updating Up Next list with \(tracks.count) tracks")
290
- template.updateSections([createUpNextSection(tracks: tracks, player: player)])
269
+ private func queueChanged(_ tracks: [Track]) {
270
+ updateUpNextButton()
271
+ guard let upNextTemplate else { return }
272
+ upNextTemplate.updateSections([createUpNextSection(tracks: tracks)])
291
273
  }
292
274
 
293
275
  // MARK: - Private - Button Handlers
@@ -295,7 +277,7 @@ final class CarPlayNowPlayingManager {
295
277
  /// Returns the appropriate image for the favorite button based on state
296
278
  private func favoriteButtonImage(isFavorited: Bool) -> UIImage {
297
279
  let symbolName = isFavorited ? "heart.fill" : "heart"
298
- guard let image = UIImage(systemName: symbolName)?.resized(
280
+ guard let image = UIImage(systemName: symbolName)?.fitted(
299
281
  to: CPNowPlayingButtonMaximumImageSize,
300
282
  scale: interfaceController.carTraitCollection.displayScale,
301
283
  ) else {
@@ -305,17 +287,15 @@ final class CarPlayNowPlayingManager {
305
287
  }
306
288
 
307
289
  private func handleShuffleButtonTapped() {
308
- guard let player = audioBrowser?.getPlayer() else { return }
309
-
290
+ guard let player else { return }
310
291
  let newEnabled = !player.shuffleEnabled
311
292
  player.shuffleEnabled = newEnabled
312
293
  logger.info("CarPlay shuffle mode changed: \(newEnabled)")
313
294
  }
314
295
 
315
296
  private func handleRepeatButtonTapped() {
316
- guard let player = audioBrowser?.getPlayer() else { return }
317
-
318
- let currentMode = player.getRepeatMode()
297
+ guard let player else { return }
298
+ let currentMode = player.repeatMode
319
299
  let newMode: RepeatMode = switch currentMode {
320
300
  case .off:
321
301
  .track
@@ -325,20 +305,22 @@ final class CarPlayNowPlayingManager {
325
305
  .off
326
306
  }
327
307
 
328
- player.setRepeatMode(newMode)
308
+ player.repeatMode = newMode
329
309
  logger.info("CarPlay repeat mode changed: \(currentMode.stringValue) → \(newMode.stringValue)")
330
310
  }
331
311
 
332
312
  private func handleFavoriteButtonTapped() {
333
- try? audioBrowser?.toggleActiveTrackFavorited()
313
+ do {
314
+ try audioBrowser.toggleActiveTrackFavorited()
315
+ } catch {
316
+ logger.error("favorite toggle failed: \(error.localizedDescription)")
317
+ }
334
318
  logger.info("CarPlay favorite toggled")
335
319
  }
336
320
 
337
321
  private func handlePlaybackRateButtonTapped() {
338
- guard let audioBrowser, let player = audioBrowser.getPlayer() else { return }
339
-
340
322
  let rates = audioBrowser.playbackRates
341
- guard !rates.isEmpty else { return }
323
+ guard !rates.isEmpty, let player else { return }
342
324
 
343
325
  let currentRate = Double(player.rate)
344
326
  let nextRate: Double
@@ -358,26 +340,20 @@ final class CarPlayNowPlayingManager {
358
340
 
359
341
  fileprivate func handleAlbumArtistButtonTapped() {
360
342
  guard let destination = albumArtistDestination else { return }
361
- let track = pendingAlbumArtistTrack ?? audioBrowser?.getPlayer()?.currentTrack
343
+ let track = pendingAlbumArtistTrack ?? player?.currentTrack
362
344
  // The album line is what was tapped, so its text is the natural title for
363
345
  // the pushed destination (artist may be unset, or the live song).
364
346
  let title = track?.album ?? track?.artist ?? track?.title ?? ""
365
347
  logger.info("Album/Artist button tapped, navigating to \(destination)")
366
- navigateToPath?(destination, title)
348
+ navigate(destination, title)
367
349
  }
368
350
 
369
- private func updateNowPlayingUpNextButton() {
370
- let template = CPNowPlayingTemplate.shared
371
- template.isUpNextButtonEnabled = (audioBrowser?.carPlayUpNextButton ?? true) && (audioBrowser?.getPlayer()?.tracks.count ?? 0) > 1
351
+ private func updateUpNextButton() {
352
+ CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
372
353
  }
373
354
 
374
355
  fileprivate func handleUpNextButtonTapped() {
375
- guard let player = audioBrowser?.getPlayer() else {
376
- logger.warning("Player not available for Up Next")
377
- return
378
- }
379
-
380
- let tracks = player.tracks
356
+ let tracks = player?.tracks ?? []
381
357
 
382
358
  guard !tracks.isEmpty else {
383
359
  logger.debug("No tracks in queue for Up Next")
@@ -392,7 +368,7 @@ final class CarPlayNowPlayingManager {
392
368
  let upNextTitle = CPNowPlayingTemplate.shared.upNextTitle
393
369
  let template = CPListTemplate(
394
370
  title: upNextTitle.isEmpty ? "Up Next" : upNextTitle,
395
- sections: [createUpNextSection(tracks: tracks, player: player)],
371
+ sections: [createUpNextSection(tracks: tracks)],
396
372
  )
397
373
 
398
374
  upNextTemplate = template
@@ -400,7 +376,7 @@ final class CarPlayNowPlayingManager {
400
376
  interfaceController.safePush(template, animated: true)
401
377
  }
402
378
 
403
- private func createUpNextSection(tracks: [Track], player: TrackPlayer) -> CPListSection {
379
+ private func createUpNextSection(tracks: [Track]) -> CPListSection {
404
380
  // Clamp to CarPlay's list limit (createSections does the same for browse
405
381
  // lists). Taking the prefix keeps item positions aligned with queue indices.
406
382
  let maxItems = CPListTemplate.maximumItemCount
@@ -408,10 +384,10 @@ final class CarPlayNowPlayingManager {
408
384
  logger.info("Up Next queue has \(tracks.count) tracks, clamping to CarPlay's limit of \(maxItems)")
409
385
  }
410
386
  let items = tracks.prefix(maxItems).enumerated().map { index, track -> CPListItem in
411
- listItemFactory(track) { [weak self] _, completion in
387
+ listItem(track) { [weak self] _, completion in
412
388
  self?.logger.info("Skipping to track at index \(index): \(track.title)")
413
389
  do {
414
- try player.skipTo(index, playWhenReady: true)
390
+ try self?.player?.skipTo(index, playWhenReady: true)
415
391
  } catch {
416
392
  self?.logger.error("Failed to skip to track: \(error.localizedDescription)")
417
393
  }
@@ -447,3 +423,17 @@ private final class NowPlayingObserver: NSObject, CPNowPlayingTemplateObserver,
447
423
  }
448
424
  }
449
425
  }
426
+
427
+ private extension UIImage {
428
+ /// Draws the image centered in `targetSize`, keeping its aspect ratio, at the car screen's
429
+ /// display scale.
430
+ func fitted(to targetSize: CGSize, scale: CGFloat) -> UIImage? {
431
+ UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
432
+ defer { UIGraphicsEndImageContext() }
433
+ let fitScale = min(targetSize.width / size.width, targetSize.height / size.height)
434
+ let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
435
+ let origin = CGPoint(x: (targetSize.width - scaledSize.width) / 2, y: (targetSize.height - scaledSize.height) / 2)
436
+ draw(in: CGRect(origin: origin, size: scaledSize))
437
+ return UIGraphicsGetImageFromCurrentImageContext()?.withRenderingMode(.alwaysTemplate)
438
+ }
439
+ }