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
@@ -1,163 +1,36 @@
1
1
  import CarPlay
2
2
  import Foundation
3
- import NitroModules
4
3
  import os.log
5
4
 
6
- /// Controller managing CarPlay templates and navigation.
7
- ///
8
- /// Responsibilities:
9
- /// - Creates and manages CPTabBarTemplate from browser tabs
10
- /// - Converts browser content to CPListTemplate for navigation
11
- /// - Handles item selection for playback and navigation
12
- /// - Delegates image loading to CarPlayImageLoader
13
- /// - Delegates Now Playing management to CarPlayNowPlayingManager
14
- ///
15
- /// This class is exposed to Objective-C for use by RNABCarPlaySceneDelegate.
5
+ /// The CarPlay scene's controller, exposed to Objective-C for `RNABCarPlaySceneDelegate`. It
6
+ /// shows a loading root until the player and a configured browser exist, then runs a
7
+ /// `CarPlaySession`; `stop()` ends it.
16
8
  @MainActor
17
9
  @objc(RNABCarPlayController)
18
10
  public final class RNABCarPlayController: NSObject {
19
- // Force the linker to include RNABMediaIntentHandler so NSClassFromString can find it
20
- @objc public static let mediaIntentHandlerClass: AnyClass = RNABMediaIntentHandler.self
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
+ ]
21
16
 
22
17
  private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayController")
23
-
24
18
  private let interfaceController: CPInterfaceController
25
- private weak var audioBrowser: HybridAudioBrowser?
26
- private var trackSelector: TrackSelector?
27
-
28
- /// Track content subscriptions
29
- private var listenerRemovals: [() -> Void] = []
30
-
31
- /// Identifies the current `start()` run; nil while stopped.
32
- ///
33
- /// A run needs an identity, not just a flag, because `start()` parks an
34
- /// untracked Task on the uncancellable `playerAndConfiguredBrowser.wait()`,
35
- /// which stays queued across a `reset()` and resumes every waiter. `restart()`
36
- /// is `stop(); start()`, so a boolean is `true` again by the time the old
37
- /// waiter resumes and both runs proceed — duplicate subscriptions and a leaked
38
- /// `NowPlayingObserver` for the session. Comparing the captured run against
39
- /// this answers "still running?" and "still me?" in one condition.
40
- private var currentRun: UInt?
41
- private var runCounter: UInt = 0
42
-
43
- private var isStarted: Bool { currentRun != nil }
44
-
45
- /// Paths whose loading template has been pushed but not yet appeared. Guards a
46
- /// rapid double-tap from pushing the same destination twice (the first push is
47
- /// async, so `topTemplate` may not reflect it yet). Cleared when it appears.
48
- private var navigatingPaths: Set<String> = []
49
-
50
- /// Templates with a content load in flight. Guards a re-entrant load when a
51
- /// still-loading template re-appears (back-and-forth navigation) from spawning
52
- /// a second resolve + watchdog.
53
- private var loadingTemplates: Set<ObjectIdentifier> = []
54
-
55
- /// Tabs received while the user had templates pushed. Rebuilding the tab bar
56
- /// replaces the root template, which tears down the pushed navigation stack —
57
- /// so the rebuild is deferred until the user is back at the tab bar.
58
- private var pendingTabs: [Track]?
59
-
60
- /// Whether a gate is active. While set, tabs keep their tab-bar entries but
61
- /// each path is resolved per-request via `gateDecision(for:)`: a gated path
62
- /// renders the gate page instead of content; an allowed path shows real
63
- /// content. Mirrors `audioBrowser.isGateActive`; seeded in start() so a gate
64
- /// set before the scene connects renders at connect.
65
- private var isGated = false
66
-
67
- /// Drops a stale tab-bar build when a newer build supersedes it
68
- /// (latest-build-wins). Shared across BOTH the gated and non-gated `showTabBar`
69
- /// paths: each bumps and captures this at entry and bails before any
70
- /// `setRootTemplate` / `updateTemplates` once it's been bumped past their
71
- /// captured value. A build suspends the main actor at its `await` points
72
- /// (per-tab `gateDecision` when gated, first-tab `loadContent` when not), so a
73
- /// gate change can interleave a build of the opposite kind — most importantly a
74
- /// `clearGate` kicking the non-gated build while a gated build is mid-await.
75
- /// Covering both paths with one token means that clear-vs-gated interleave is
76
- /// caught too (not just gated-vs-gated), so a stale gated build can't paint a
77
- /// gate page over just-cleared content. Mirrors `albumArtistGeneration` in
78
- /// `CarPlayNowPlayingManager`.
79
- private var gateBuildGeneration: UInt = 0
80
-
81
- /// How long a browse resolve may run before the destination's loading spinner
82
- /// is replaced with an error state. The selection completion is fired
83
- /// immediately (so CarPlay never blocks the list — per Apple's async handler
84
- /// guidance we push the destination and fill it in), so this only bounds how
85
- /// long the *destination screen* spins. Backing out and re-tapping retries.
86
- private let resolveTimeout: Duration = .seconds(15)
87
-
88
- /// Helper object for CPInterfaceControllerDelegate conformance
89
19
  private var interfaceDelegate: InterfaceControllerDelegate?
20
+ private var instanceSubscription: Subscription?
90
21
 
91
- /// Image loading service for CarPlay artwork
92
- private var imageLoader: CarPlayImageLoader?
93
-
94
- /// List item and section factory
95
- private var listItemFactory: CarPlayListItemFactory?
96
-
97
- /// Now Playing template and button management
98
- private let nowPlayingManager: CarPlayNowPlayingManager
99
-
100
- /// Convenience accessor for browser config
101
- private var config: BrowserConfig {
102
- audioBrowser?.browserManager.config ?? BrowserConfig()
103
- }
104
-
105
- /// The configured list playing-indicator location, in CarPlay-framework terms.
106
- private var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
107
- switch audioBrowser?.carPlayPlayingIndicatorLocation {
108
- case .trailing: .trailing
109
- case .leading, nil: .leading
110
- }
111
- }
112
-
113
- /// The tabs the tab bar actually shows. A disabled tab hides rather than
114
- /// greys: `CPTabBarTemplate` has no disabled affordance, so per `Track`'s
115
- /// rendering ladder an unavailable tab must not render as a normal-looking
116
- /// control (matches Android Auto, which filters before its 4-tab cap).
117
- ///
118
- /// Named once because every site that reasons about the tab bar must agree on
119
- /// it — `showTabBar` builds from this list, and `handleTabsChanged` compares
120
- /// against it. Those two disagreeing is exactly what made the in-place
121
- /// restamp dead code whenever any tab was disabled.
122
- private static func visibleTabs(_ tabs: [Track]) -> [Track] {
123
- tabs.filter { $0.disabled != true }
124
- }
125
-
126
- /// Whether a list item identifies the currently active (loaded) track.
127
- ///
128
- /// Compares track identities (`id` when non-blank, else `src`): the active
129
- /// track may have been loaded outside the browse tree (the consumer's own
130
- /// `load`/`setQueue`) with a `src` that differs textually from the browse
131
- /// row's (absolute vs relative, extra query params) while being the same
132
- /// item. A nil identity on either side never matches.
133
- private func isActiveTrack(identity: String?) -> Bool {
134
- guard let identity, let currentTrack = audioBrowser?.getPlayer()?.currentTrack else { return false }
135
- return currentTrack.identity == identity
136
- }
137
-
138
- // MARK: - Initialization
22
+ /// The pending or running start; nil while stopped.
23
+ private var run: Task<Void, Never>?
24
+ private(set) var session: CarPlaySession?
139
25
 
140
26
  @objc
141
27
  public init(interfaceController: CPInterfaceController) {
142
28
  self.interfaceController = interfaceController
143
- audioBrowser = HybridAudioBrowser.shared
144
- nowPlayingManager = CarPlayNowPlayingManager(interfaceController: interfaceController)
145
29
  super.init()
146
- nowPlayingManager.listItemFactory = { [weak self] track, handler in
147
- self?.listItemFactory?.createListItem(for: track, handler: handler)
148
- ?? CPListItem(text: track.title, detailText: nil)
149
- }
150
- nowPlayingManager.navigateToPath = { [weak self] path, title in
151
- Task { @MainActor in await self?.navigateToPath(path, title: title) }
152
- }
153
30
  }
154
31
 
155
- // MARK: - Lifecycle
156
-
157
- /// Reports CarPlay scene connection to the library (drives the JS-side
158
- /// `isCarPlayConnected` / `onCarPlayConnectedChanged`). Called by
159
- /// RNABCarPlaySceneDelegate on scene connect/disconnect — deliberately not
160
- /// from start()/stop(), which also cycle on a JS runtime reload.
32
+ /// Reports the scene's connection (JS `isCarPlayConnected`). Called on scene connect and
33
+ /// disconnect, not from `start()`/`stop()`, which also cycle on a JS reload.
161
34
  @objc
162
35
  public static func setConnected(_ connected: Bool) {
163
36
  HybridAudioBrowser.setCarPlayConnected(connected)
@@ -165,1252 +38,57 @@ public final class RNABCarPlayController: NSObject {
165
38
 
166
39
  @objc
167
40
  public func start() {
168
- guard !isStarted else { return }
169
- runCounter &+= 1
170
- currentRun = runCounter
171
- let run = runCounter
172
-
41
+ guard run == nil else { return }
173
42
  logger.info("Starting CarPlay controller")
174
43
 
175
- // Set up interface controller delegate for template lifecycle events
176
44
  let delegate = InterfaceControllerDelegate(controller: self)
177
45
  interfaceDelegate = delegate
178
46
  interfaceController.delegate = delegate
179
47
 
180
- // Restart when a new HybridAudioBrowser replaces the shared instance
181
- // (JS runtime reload) — our subscriptions point at the old instance's
182
- // emitters and would otherwise go silent.
183
- let instanceToken = HybridAudioBrowser.instanceChangedEmitter.addListener { [weak self] _ in
184
- Task { @MainActor in
185
- self?.restart()
186
- }
48
+ // A JS reload replaces the bridge, and with it every emitter the session listens to.
49
+ instanceSubscription = HybridAudioBrowser.instanceChangedEmitter.subscribeOnMain { [weak self] in
50
+ self?.restart()
187
51
  }
188
- listenerRemovals.append {
189
- HybridAudioBrowser.instanceChangedEmitter.removeListener(instanceToken)
190
- }
191
-
192
- // Show loading template while waiting
193
- showLoadingTemplate()
194
52
 
195
- // Wait for both browser and player to be ready
196
- Task { @MainActor in
197
- let (browser, _) = await playerAndConfiguredBrowser.wait()
198
- // Answers "still running?" and "still this run?" at once.
199
- guard self.currentRun == run else { return }
200
- self.logger.debug("AudioBrowser and player ready, setting up CarPlay")
201
- self.audioBrowser = browser
202
- self.isGated = browser.isGateActive
203
- self.trackSelector = TrackSelector(browserManager: browser.browserManager)
204
-
205
- // Create image loader with CarPlay display traits
206
- self.imageLoader = CarPlayImageLoader(
207
- carTraitCollection: self.interfaceController.carTraitCollection,
208
- browserManager: browser.browserManager,
209
- )
53
+ // Before the config loads, the app's loading title isn't known: the spinner only.
54
+ let browserManager = HybridAudioBrowser.shared?.browserManager
55
+ let loadingTitle = browserManager?.isConfigured == true ? browserManager?.config.carPlayLoadingTitle : nil
56
+ interfaceController.safeSetRoot(
57
+ CPListTemplate.loading(title: nil, path: nil, loadingTitle: loadingTitle), animated: false,
58
+ )
210
59
 
211
- // Create list item factory
212
- let factory = CarPlayListItemFactory(
213
- isActiveTrack: { [weak self] identity in self?.isActiveTrack(identity: identity) ?? false },
214
- onItemSelected: { [weak self] track, completion in
215
- self?.handleItemSelection(track: track, completion: completion)
216
- },
60
+ run = Task { [weak self] in
61
+ guard let (browser, _) = await playerAndConfiguredBrowser.wait(), !Task.isCancelled,
62
+ let self
63
+ else { return }
64
+ let session = CarPlaySession(
65
+ interfaceController: interfaceController, audioBrowser: browser,
217
66
  )
218
- factory.imageLoader = self.imageLoader
219
- factory.playingIndicatorLocation = self.playingIndicatorLocation
220
- self.listItemFactory = factory
221
-
222
- self.setupContentSubscriptions()
223
-
224
- // Wire up now playing manager
225
- self.nowPlayingManager.setup(audioBrowser: browser)
226
-
227
- await self.buildInitialInterface()
67
+ self.session = session
68
+ session.start()
228
69
  }
229
70
  }
230
71
 
231
72
  @objc
232
73
  public func stop() {
233
- guard isStarted else { return }
234
- currentRun = nil
235
-
74
+ guard run != nil else { return }
236
75
  logger.info("Stopping CarPlay controller")
237
-
238
- // Remove all emitter listeners
239
- for removal in listenerRemovals {
240
- removal()
241
- }
242
- listenerRemovals.removeAll()
243
-
244
- // Clear config callback
245
- audioBrowser?.browserManager.onConfigChanged = nil
246
- audioBrowser?.browserManager.onFavoritesChanged = nil
247
-
248
- nowPlayingManager.teardown()
249
- listItemFactory = nil
250
-
251
- navigatingPaths.removeAll()
252
- loadingTemplates.removeAll()
253
- pendingTabs = nil
76
+ run?.cancel()
77
+ run = nil
78
+ session?.end()
79
+ session = nil
80
+ instanceSubscription = nil
254
81
  }
255
82
 
256
- /// Full stop/start cycle. Used when the JS runtime reloads: the new
257
- /// HybridAudioBrowser instance replaces the emitters this controller is
258
- /// subscribed to and resets the readiness gate, so everything must be
259
- /// re-subscribed against the live instance (start() waits for it).
260
- @MainActor
261
83
  private func restart() {
262
- guard isStarted else { return }
84
+ guard run != nil else { return }
263
85
  logger.info("AudioBrowser instance changed — restarting CarPlay controller")
264
86
  stop()
265
87
  start()
266
88
  }
267
-
268
- // MARK: - Content Subscriptions
269
-
270
- private func setupContentSubscriptions() {
271
- guard let audioBrowser else {
272
- logger.warning("AudioBrowser not available for CarPlay")
273
- return
274
- }
275
-
276
- // Subscribe to tab changes
277
- let tabsToken = audioBrowser.tabsChangedEmitter.addListener { [weak self] tabs in
278
- Task { @MainActor in
279
- self?.handleTabsChanged(tabs)
280
- }
281
- }
282
- listenerRemovals.append { [weak audioBrowser] in
283
- audioBrowser?.tabsChangedEmitter.removeListener(tabsToken)
284
- }
285
-
286
- // Subscribe to content changes
287
- let contentToken = audioBrowser.contentChangedEmitter.addListener { [weak self] content in
288
- Task { @MainActor in
289
- self?.handleContentChanged(content)
290
- }
291
- }
292
- listenerRemovals.append { [weak audioBrowser] in
293
- audioBrowser?.contentChangedEmitter.removeListener(contentToken)
294
- }
295
-
296
- // Subscribe to config changes (for Now Playing buttons and per-track
297
- // button state — e.g. resolveAlbumPath appearing/disappearing)
298
- audioBrowser.browserManager.onConfigChanged = { [weak self] _ in
299
- Task { @MainActor in
300
- self?.nowPlayingManager.setupNowPlayingButtons()
301
- self?.nowPlayingManager.updateNowPlayingButtonStates()
302
- }
303
- }
304
-
305
- // Now-playing buttons + Up Next + the list playing-indicator location are
306
- // runtime-updatable via updateOptions(); refresh them when player options
307
- // change (configureBrowser is no longer the only path that can change them).
308
- let optionsToken = audioBrowser.playerOptionsChangedEmitter.addListener { [weak self] in
309
- Task { @MainActor in
310
- guard let self else { return }
311
- self.nowPlayingManager.setupNowPlayingButtons()
312
- self.nowPlayingManager.updateNowPlayingButtonStates()
313
- // The factory's copy doubles as the last-applied value: sweep the
314
- // templates only when the location actually changed, not on every
315
- // updateOptions() call.
316
- let location = self.playingIndicatorLocation
317
- if let factory = self.listItemFactory, factory.playingIndicatorLocation != location {
318
- factory.playingIndicatorLocation = location
319
- self.updatePlayingIndicators()
320
- }
321
- }
322
- }
323
- listenerRemovals.append { [weak audioBrowser] in
324
- audioBrowser?.playerOptionsChangedEmitter.removeListener(optionsToken)
325
- }
326
-
327
- // Refresh the Now Playing heart when favorites change externally (app /
328
- // webview). The favorite/active-track emitters only fire for the player's
329
- // own toggles, so without this an in-app favorite leaves the CarPlay heart
330
- // stale.
331
- audioBrowser.browserManager.onFavoritesChanged = { [weak self] in
332
- Task { @MainActor in
333
- self?.nowPlayingManager.updateFavoriteButtonState()
334
- }
335
- }
336
-
337
- // Subscribe to favorite changes (for Now Playing button)
338
- let favoriteToken = audioBrowser.favoriteChangedEmitter.addListener { [weak self] _ in
339
- Task { @MainActor in
340
- self?.nowPlayingManager.updateFavoriteButtonState()
341
- }
342
- }
343
- listenerRemovals.append { [weak audioBrowser] in
344
- audioBrowser?.favoriteChangedEmitter.removeListener(favoriteToken)
345
- }
346
-
347
- // Repeat/shuffle mode changes re-stamp the now-playing buttons' serialized
348
- // `isSelected` (a rebuild, skipped when unchanged) so CarPlay re-renders
349
- // draw the correct selected background on the first frame — see
350
- // CarPlayNowPlayingManager.BuiltButtonRow.
351
- let repeatToken = audioBrowser.repeatModeChangedEmitter.addListener { [weak self] _ in
352
- Task { @MainActor in
353
- self?.nowPlayingManager.setupNowPlayingButtons()
354
- }
355
- }
356
- listenerRemovals.append { [weak audioBrowser] in
357
- audioBrowser?.repeatModeChangedEmitter.removeListener(repeatToken)
358
- }
359
- let shuffleToken = audioBrowser.shuffleChangedEmitter.addListener { [weak self] _ in
360
- Task { @MainActor in
361
- self?.nowPlayingManager.setupNowPlayingButtons()
362
- }
363
- }
364
- listenerRemovals.append { [weak audioBrowser] in
365
- audioBrowser?.shuffleChangedEmitter.removeListener(shuffleToken)
366
- }
367
-
368
- // Subscribe to external content changes (notifyContentChanged /
369
- // invalidateAllContent).
370
- let externalContentToken = audioBrowser.externalContentChangedEmitter.addListener { [weak self] path in
371
- // Hop to the main actor like the sibling listeners — the emitter runs
372
- // listeners synchronously on the emitting (JS) thread.
373
- Task { @MainActor in
374
- if path == HybridAudioBrowser.invalidateAllSentinel {
375
- self?.invalidateAllContent()
376
- } else {
377
- self?.notifyContentChanged(path: path)
378
- }
379
- }
380
- }
381
- listenerRemovals.append { [weak audioBrowser] in
382
- audioBrowser?.externalContentChangedEmitter.removeListener(externalContentToken)
383
- }
384
-
385
- // Subscribe to gate changes (set / in-place update / clear)
386
- let gateToken = audioBrowser.gateChangedEmitter.addListener { [weak self] active in
387
- Task { @MainActor in
388
- self?.handleGateChanged(active)
389
- }
390
- }
391
- listenerRemovals.append { [weak audioBrowser] in
392
- audioBrowser?.gateChangedEmitter.removeListener(gateToken)
393
- }
394
-
395
- // Subscribe to active track changes (for playing indicator in lists)
396
- let activeTrackToken = audioBrowser.activeTrackChangedEmitter.addListener { [weak self] event in
397
- Task { @MainActor in
398
- self?.handleActiveTrackChanged(event)
399
- }
400
- }
401
- listenerRemovals.append { [weak audioBrowser] in
402
- audioBrowser?.activeTrackChangedEmitter.removeListener(activeTrackToken)
403
- }
404
-
405
- // Subscribe to queue changes (for Up Next list updates)
406
- let queueToken = audioBrowser.queueChangedEmitter.addListener { [weak self] tracks in
407
- Task { @MainActor in
408
- self?.nowPlayingManager.handleQueueChanged(tracks)
409
- }
410
- }
411
- listenerRemovals.append { [weak audioBrowser] in
412
- audioBrowser?.queueChangedEmitter.removeListener(queueToken)
413
- }
414
-
415
- // A voice media intent started playback — surface Now Playing so the user
416
- // lands on the playing station (Up Next holds the rest of the results).
417
- let showNowPlayingToken = audioBrowser.showNowPlayingRequestedEmitter.addListener { [weak self] _ in
418
- Task { @MainActor in
419
- self?.nowPlayingManager.showNowPlaying()
420
- }
421
- }
422
- listenerRemovals.append { [weak audioBrowser] in
423
- audioBrowser?.showNowPlayingRequestedEmitter.removeListener(showNowPlayingToken)
424
- }
425
-
426
- // Subscribe to navigation errors (from browser layer)
427
- let navErrorToken = audioBrowser.navigationErrorEmitter.addListener { [weak self] event in
428
- Task { @MainActor in
429
- self?.handleNavigationError(event)
430
- }
431
- }
432
- listenerRemovals.append { [weak audioBrowser] in
433
- audioBrowser?.navigationErrorEmitter.removeListener(navErrorToken)
434
- }
435
- }
436
-
437
- /// Handles navigation errors from the browser layer, displaying them in CarPlay
438
- @MainActor
439
- private func handleNavigationError(_ event: NavigationErrorEvent) {
440
- guard let error = event.error else { return }
441
- logger.warning("Navigation error: \(error.code.stringValue) - \(error.message)")
442
- // Derive the current path from the live top template (authoritative) rather
443
- // than a hand-maintained stack that goes stale on back navigation.
444
- let path = interfaceController.topTemplate.flatMap { getPath(from: $0) } ?? "/"
445
- showNavigationError(error, path: path)
446
- }
447
-
448
- // MARK: - Initial Interface
449
-
450
- @MainActor
451
- private func buildInitialInterface() async {
452
- guard let audioBrowser else {
453
- logger.error("AudioBrowser not available")
454
- await showRootNavigationError(.code(.unknownError))
455
- return
456
- }
457
-
458
- // Get tabs from browser manager
459
- let tabs = audioBrowser.browserManager.getTabs()
460
-
461
- if let tabs, !tabs.isEmpty {
462
- await showTabBar(tabs: tabs)
463
- } else {
464
- // No tabs yet - query them
465
- logger.info("No tabs available, querying...")
466
- // Config exists by now (start() waited for it), so re-show the loading
467
- // root to pick up the app's localized loading title — the first loading
468
- // template was created before config was available.
469
- if config.carPlayLoadingTitle != nil {
470
- showLoadingTemplate()
471
- }
472
- do {
473
- // An empty query result routes to the .emptyContent error page inside
474
- // showTabBar, shared with runtime tab changes.
475
- let queriedTabs = try await audioBrowser.browserManager.queryTabs()
476
- await showTabBar(tabs: queriedTabs)
477
- } catch {
478
- logger.error("Failed to query tabs: \(error.localizedDescription)")
479
- await showRootNavigationError(NavigationError.from(error))
480
- }
481
- }
482
- }
483
-
484
- /// Shows a loading template as root while waiting for initialization. Before
485
- /// the browser is configured this can only show the spinner (iOS 18.4+) — the
486
- /// app's `carPlayLoadingTitle` isn't known yet; `buildInitialInterface`
487
- /// re-shows it once config is available.
488
- private func showLoadingTemplate() {
489
- let template = makeLoadingTemplate(title: nil, path: nil)
490
- interfaceController.safeSetRoot(template, animated: false)
491
- }
492
-
493
- // MARK: - Tab Bar
494
-
495
- @MainActor
496
- private func showTabBar(tabs allTabs: [Track]) async {
497
- let tabs = Self.visibleTabs(allTabs)
498
-
499
- // Serialize concurrent tab-bar builds across BOTH the gated and non-gated
500
- // paths with one shared generation token: bump at entry, capture, and bail
501
- // before any template mutation once a newer build supersedes us. Both paths
502
- // suspend the main actor at `await` points (per-tab `gateDecision` when
503
- // gated, first-tab `loadContent` when not), so a gate change can interleave
504
- // a build of the opposite kind — a `clearGate` kicking the non-gated build
505
- // while a gated build is mid-await, or vice versa. A single token covers the
506
- // clear-vs-gated interleave too, not just gated-vs-gated: the superseding
507
- // build bumps it, and the in-flight build bails before painting (preventing
508
- // a stale gate page over just-cleared content, or a clobbered root template).
509
- gateBuildGeneration &+= 1
510
- let generation = gateBuildGeneration
511
-
512
- // While gated, each tab path is resolved per-request: a gated tab renders
513
- // the gate page with that request's chrome; an allowed tab shows real
514
- // content. This generalizes the old "all tabs show the gate" — a path the
515
- // resolver allows now shows real content.
516
- //
517
- // Ahead of the empty guard below, because a gated build from zero tabs is
518
- // NOT blank: it paints a full-screen gate page as root (a gate raised
519
- // before config loads is exactly that case). `showGatedTabBar` owns the
520
- // empty-tabs decision and falls back to the same .emptyContent page when
521
- // the resolver allows the root.
522
- if isGated {
523
- await showGatedTabBar(tabs: tabs, generation: generation)
524
- return
525
- }
526
-
527
- // A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
528
- // to the .emptyContent error page like every other failure (formatted
529
- // through the app's formatNavigationError, ADR 0001). The guard lives at
530
- // this choke point — both the initial build and runtime tab changes pass
531
- // through here.
532
- guard !tabs.isEmpty else {
533
- await showRootNavigationError(.code(.emptyContent))
534
- return
535
- }
536
-
537
- logger.info("Building tab bar with \(tabs.count) tabs")
538
-
539
- let maxTabs = CPTabBarTemplate.maximumTabCount
540
-
541
- // Create tab templates synchronously (empty shells) - don't block on content loading
542
- let tabTemplates: [CPListTemplate] = tabs.prefix(maxTabs).map { tab in
543
- createTabTemplate(for: tab)
544
- }
545
-
546
- // A newer build (e.g. a gate raised mid-clear) superseded us — don't clobber
547
- // its root template.
548
- guard gateBuildGeneration == generation else { return }
549
-
550
- // Set the tab bar immediately so UI appears fast
551
- logger.info("Setting tab bar root template with \(tabTemplates.count) templates")
552
- let tabBar = CPTabBarTemplate(templates: tabTemplates)
553
- interfaceController.safeSetRoot(tabBar, animated: true)
554
-
555
- // Load content for the first tab only - others load lazily when selected
556
- if let firstTemplate = tabTemplates.first, let firstTab = tabs.first, let path = firstTab.path {
557
- await loadContent(for: path, into: firstTemplate)
558
- }
559
- }
560
-
561
- /// Builds the tab bar while a gate is active. Each tab's path is run through
562
- /// the single gate choke point (`gateDecision(for:)`): a gated tab becomes a
563
- /// gate page carrying that decision's chrome (and fires `onGate`); an allowed
564
- /// tab becomes a normal content shell that lazy-loads. With a static gate (no
565
- /// resolver) every tab is gated with the default chrome — today's behaviour.
566
- ///
567
- /// `generation` is the shared `gateBuildGeneration` captured by the calling
568
- /// `showTabBar`; we bail before any template mutation once a newer build (of
569
- /// either kind) bumps it past `generation`.
570
- @MainActor
571
- private func showGatedTabBar(tabs: [Track], generation: UInt) async {
572
- guard let audioBrowser else { return }
573
-
574
- guard !tabs.isEmpty else {
575
- // Tabs unknown (config not loaded yet, or none) — resolve the root path
576
- // and show a single gate page.
577
- let outcome = await audioBrowser.gateDecision(
578
- for: NativeGateRequest(reason: .browse, path: nil, search: nil),
579
- )
580
- guard gateBuildGeneration == generation else { return } // superseded by a newer build
581
- if outcome.gated {
582
- audioBrowser.onGate(GateEvent(reason: .browse))
583
- interfaceController.safeSetRoot(
584
- makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
585
- )
586
- } else {
587
- // The resolver allows the root, and there are no tabs to show — the
588
- // same nothing-to-render case the ungated build routes to .emptyContent.
589
- // Without this the loading spinner would stay as root forever.
590
- await showRootNavigationError(.code(.emptyContent))
591
- }
592
- return
593
- }
594
-
595
- let limitedTabs = Array(tabs.prefix(CPTabBarTemplate.maximumTabCount))
596
- var templates: [CPListTemplate] = []
597
- for tab in limitedTabs {
598
- let outcome = await audioBrowser.gateDecision(
599
- for: NativeGateRequest(reason: .browse, path: tab.path, search: nil),
600
- )
601
- guard gateBuildGeneration == generation else { return } // superseded by a newer build
602
- if outcome.gated {
603
- audioBrowser.onGate(GateEvent(reason: .browse))
604
- templates.append(makeGateTemplate(gate: outcome.chrome, tab: tab))
605
- } else {
606
- // This path is allowed even though a gate is active — show real content.
607
- templates.append(createTabTemplate(for: tab))
608
- }
609
- }
610
-
611
- if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
612
- tabBar.templates.count == templates.count
613
- {
614
- // Equal-count in-place swap keeps the selected tab index.
615
- tabBar.updateTemplates(templates)
616
- } else {
617
- interfaceController.safeSetRoot(
618
- CPTabBarTemplate(templates: templates), animated: true,
619
- )
620
- }
621
-
622
- // Eagerly fill any allowed (content) tabs: templateDidAppear isn't
623
- // guaranteed to re-fire for templates swapped in via updateTemplates.
624
- for (tab, template) in zip(limitedTabs, templates) {
625
- guard getPath(from: template) != nil, let path = tab.path else { continue }
626
- await loadContent(for: path, into: template)
627
- }
628
- }
629
-
630
- /// Creates a tab template shell without loading content (synchronous).
631
- /// The shell carries the loading state (spinner / `carPlayLoadingTitle`)
632
- /// until its content lazy-loads on first appearance.
633
- private func createTabTemplate(for track: Track) -> CPListTemplate {
634
- // The path stored on the template drives lazy loading and refresh.
635
- let template = makeLoadingTemplate(title: track.title, path: track.path)
636
- applyTabBarEntry(to: template, for: track)
637
- return template
638
- }
639
-
640
- /// Stamps a template's tab-bar entry: title and image (default, SF Symbol,
641
- /// or loaded artwork). Shared by content tabs and gate-page tabs so a gate
642
- /// keeps the familiar tab bar.
643
- private func applyTabBarEntry(to template: CPTemplate, for track: Track) {
644
- // Set tab title explicitly (required for tab bar display)
645
- template.tabTitle = track.title
646
-
647
- // Set tab image only when the tab supplies one (SF Symbol / artwork /
648
- // artworkSource). A tab with no icon stays title-only — CPTemplate renders
649
- // fine with just `tabTitle`. Tab bar icons are 24pt x 24pt per the CarPlay
650
- // Developer Guide: https://developer.apple.com/download/files/CarPlay-Developer-Guide.pdf
651
- if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
652
- let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
653
- if let image = imageLoader?.sfSymbolImage(symbolName) {
654
- template.tabImage = image
655
- }
656
- } else if track.artwork != nil || track.artworkSource != nil {
657
- // loadArtwork handles both artwork and artworkSource
658
- let tabImageSize = CGSize(width: 24, height: 24)
659
- imageLoader?.loadArtwork(for: track, size: tabImageSize) { [weak template] image in
660
- Task { @MainActor in
661
- if let image {
662
- template?.tabImage = image
663
- }
664
- }
665
- }
666
- } else {
667
- // No icon supplied: clear any prior image so a restamp (handleTabsChanged)
668
- // of an existing template doesn't leave a stale icon on a now title-only tab.
669
- template.tabImage = nil
670
- }
671
- }
672
-
673
- // MARK: - List Templates
674
-
675
- /// Finds the path associated with a template, if any
676
- private func getPath(from template: CPTemplate) -> String? {
677
- (template.userInfo as? [String: Any])?["path"] as? String
678
- }
679
-
680
- /// Updates a template's sections and assistant cell from resolved content.
681
- private func updateTemplate(_ template: CPListTemplate, with resolvedTrack: ResolvedTrack) {
682
- let sections = listItemFactory?.createSections(from: resolvedTrack) ?? []
683
- template.updateSections(sections)
684
- configureAssistantCell(on: template, from: resolvedTrack)
685
- }
686
-
687
- /// Configures the assistant cell ("Ask Siri to Play Audio") on a template
688
- /// based on the `carPlaySiriListButton` property of the resolved content.
689
- private func configureAssistantCell(on template: CPListTemplate, from resolvedTrack: ResolvedTrack) {
690
- guard let position = resolvedTrack.carPlaySiriListButton else {
691
- template.assistantCellConfiguration = nil
692
- return
693
- }
694
- let cellPosition: CPListItem.AssistantCellPosition = position == .top ? .top : .bottom
695
- template.assistantCellConfiguration = CPAssistantCellConfiguration(
696
- position: cellPosition,
697
- visibility: .always,
698
- assistantAction: .playMedia,
699
- )
700
- }
701
-
702
- // MARK: - Content Loading
703
-
704
- /// Resolves `path` and fills an empty (loading) template. Driven by
705
- /// `templateDidAppear` → `loadContentIfNeeded`, so it runs once the template is
706
- /// on screen — the timing where CarPlay actually applies updates (updates made
707
- /// earlier, e.g. straight after push, are dropped). Shows a centered empty
708
- /// state on empty/error/timeout.
709
- @MainActor
710
- private func loadContent(for path: String, into template: CPListTemplate) async {
711
- guard let audioBrowser else { return }
712
-
713
- // Single-flight: don't start a second load if one is already in flight for
714
- // this template (e.g. the user backed out and into a still-loading screen).
715
- let templateId = ObjectIdentifier(template)
716
- guard !loadingTemplates.contains(templateId) else { return }
717
- loadingTemplates.insert(templateId)
718
- defer { loadingTemplates.remove(templateId) }
719
-
720
- // Watchdog: if still loading (no rows) after the timeout, show an error.
721
- let timeout = resolveTimeout
722
- let watchdog = Task { @MainActor [weak self] in
723
- do { try await Task.sleep(for: timeout) } catch { return } // cancelled → abort
724
- guard let self, template.sections.isEmpty else { return }
725
- self.logger.error("loadContent: resolve timed out for \(path)")
726
- // A timeout is a navigation error (code .timeout) too, so it formats through
727
- // the app's formatNavigationError like every other browse failure.
728
- let timedOut = NavigationError(
729
- code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil,
730
- )
731
- await self.showNavigationErrorView(timedOut, path: path, on: template)
732
- }
733
-
734
- do {
735
- let resolved = try await audioBrowser.browserManager.resolve(path, useCache: true)
736
- watchdog.cancel()
737
- // A page carrying an assistant cell (carPlaySiriListButton) is meaningful
738
- // even with no rows — the "Ask Siri to Play" cell IS its content — so it
739
- // renders rather than falling through to the empty-content state.
740
- let hasAssistantCell = resolved.carPlaySiriListButton != nil
741
- // A page of empty sections is still empty.
742
- let hasChildren = resolved.normalizedSections?.contains { !$0.children.isEmpty } ?? false
743
- if !hasChildren, !hasAssistantCell {
744
- // Empty is modeled as a navigation error (code .emptyContent) so it goes
745
- // through the same path-aware formatter as failures — letting an app give
746
- // an empty Favorites tab different copy than an empty search. ADR 0001.
747
- await showNavigationErrorView(.code(.emptyContent), path: path, on: template)
748
- } else {
749
- updateTemplate(template, with: resolved)
750
- }
751
- } catch {
752
- watchdog.cancel()
753
- logger.error("Failed to load content for \(path): \(error.localizedDescription)")
754
- await showNavigationErrorView(NavigationError.from(error), path: path, on: template)
755
- }
756
- }
757
-
758
- /// Renders a navigation error as the template's centered empty/error view,
759
- /// formatted via the app's `formatNavigationError` (or the built-in default):
760
- /// `title → view title`, `message → subtitle`. Used for both real failures and
761
- /// the empty-content case (ADR 0001).
762
- @MainActor
763
- private func showNavigationErrorView(
764
- _ navError: NavigationError,
765
- path: String,
766
- on template: CPListTemplate,
767
- ) async {
768
- let formatted = await formattedNavigationError(navError, path: path)
769
- showMessage(
770
- on: template,
771
- title: formatted.title,
772
- subtitle: formatted.message.flatMap { $0.isEmpty ? nil : $0 },
773
- )
774
- }
775
-
776
- /// Resolves a navigation error to its display form via the app's
777
- /// `formatNavigationError` callback, falling back to the default if unset/failed.
778
- @MainActor
779
- private func formattedNavigationError(
780
- _ navError: NavigationError,
781
- path: String,
782
- ) async -> FormattedNavigationError {
783
- let fallback = navError.defaultFormatted()
784
- guard let formatter = config.formatNavigationError else { return fallback }
785
- let params = FormatNavigationErrorParams(
786
- error: navError, defaultFormatted: fallback, path: path,
787
- kind: NavigationErrorKind(path: path),
788
- )
789
- do {
790
- let custom = try await formatter(params).await()
791
- return custom ?? fallback
792
- } catch {
793
- return fallback
794
- }
795
- }
796
-
797
- // MARK: - Selection Handling
798
-
799
- private func handleItemSelection(track: Track, completion: @escaping () -> Void) {
800
- logger.info("Selected track: \(track.title)")
801
-
802
- guard let audioBrowser else {
803
- completion()
804
- return
805
- }
806
-
807
- // If this track is already loaded, resume playback and show Now Playing.
808
- if track.src != nil, isActiveTrack(identity: track.identity) {
809
- try? audioBrowser.play()
810
- nowPlayingManager.showNowPlaying()
811
- completion()
812
- return
813
- }
814
-
815
- guard let player = audioBrowser.getPlayer(), let trackSelector else {
816
- completion()
817
- return
818
- }
819
-
820
- // A playable track always lands on the Now Playing surface (selection
821
- // resolves to .play or .intercepted, never .browse) — push it now and stamp
822
- // the tapped track's metadata, instead of after selection resolves: queue
823
- // expansion and the media URL resolve can both hit the network, and waiting
824
- // animates in a blank Now Playing screen. The load pipeline re-publishes
825
- // the same fields and dedupes.
826
- if track.src != nil {
827
- player.loadNowPlayingMetadata(for: track)
828
- // Derive the album line's destination from the tapped track before the
829
- // push — CarPlay reads isAlbumArtistButtonEnabled at display time.
830
- nowPlayingManager.prepareAlbumArtistButton(for: track)
831
- nowPlayingManager.showNowPlaying()
832
- }
833
-
834
- // Release CarPlay immediately so the list never locks up. Apple's handler
835
- // guidance is to finish processing the tap promptly; for a browse we "finish"
836
- // by pushing the destination and filling it in (see navigateToPath), and for
837
- // playback the Now Playing surface owns its own loading state.
838
- completion()
839
-
840
- Task { [weak self] in
841
- let result = await trackSelector.select(track: track, player: player)
842
- guard let self else { return }
843
- switch result {
844
- case let .play(intent):
845
- self.executePlayback(intent, player: player)
846
- self.nowPlayingManager.showNowPlaying(popToFront: false)
847
- case .intercepted:
848
- self.nowPlayingManager.showNowPlaying(popToFront: false)
849
- case let .browse(path):
850
- await self.navigateToPath(path, title: track.title)
851
- case .none:
852
- break
853
- }
854
- }
855
- }
856
-
857
- private func executePlayback(_ intent: TrackSelector.PlaybackIntent, player: TrackPlayer) {
858
- switch intent {
859
- case let .skipTo(index):
860
- try? player.skipTo(index, playWhenReady: true)
861
- case let .setQueue(tracks, startIndex, sourcePath):
862
- player.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: sourcePath)
863
- case let .loadTrack(track):
864
- player.load(track, playWhenReady: true)
865
- }
866
- }
867
-
868
- /// Pushes a browsable path's destination immediately as an empty, spinning list
869
- /// template — so the list the user tapped from is never blocked. The content is
870
- /// filled by `templateDidAppear` → `loadContentIfNeeded` → `loadContent`, which
871
- /// runs once the template is on screen (the timing CarPlay needs: updates made
872
- /// right after a push are dropped). Backing out and re-tapping retries.
873
- ///
874
- /// One exception to pushing: a path that IS a tab selects that tab (see
875
- /// `selectTab`).
876
- @MainActor
877
- private func navigateToPath(_ path: String, title: String) async {
878
- // The gate is resolved per-request: a gated path pushes the gate page
879
- // (with this request's chrome) instead of browsing into content; this also
880
- // blocks indirect entries like the Now Playing album line. An allowed path
881
- // browses normally even while a gate is active.
882
- if isGated, let audioBrowser {
883
- let outcome = await audioBrowser.gateDecision(
884
- for: NativeGateRequest(reason: .browse, path: path, search: nil),
885
- )
886
- if outcome.gated {
887
- audioBrowser.onGate(GateEvent(reason: .browse))
888
- if let top = interfaceController.topTemplate, getPath(from: top) == path { return }
889
- interfaceController.safePush(
890
- makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
891
- )
892
- return
893
- }
894
- }
895
- // A link to a tab's own path switches to that tab instead of pushing a
896
- // second copy of it into the current tab's stack.
897
- if selectTab(owning: path) { return }
898
- // Avoid pushing a duplicate if the top template already shows this path.
899
- if let top = interfaceController.topTemplate, getPath(from: top) == path {
900
- return
901
- }
902
- // …and guard a rapid double-tap whose first push hasn't appeared yet (so the
903
- // check above can't see it). Cleared when the pushed template appears.
904
- guard !navigatingPaths.contains(path) else { return }
905
- navigatingPaths.insert(path)
906
-
907
- let template = makeLoadingTemplate(title: title, path: path)
908
- interfaceController.safePush(template, animated: true) { [weak self] pushed, error in
909
- guard !pushed else { return }
910
- // The push can fail (e.g. CarPlay's template stack depth limit). The
911
- // appear callback that normally clears the guard will never fire, so
912
- // clear it here — otherwise the destination stays unreachable until
913
- // CarPlay reconnects.
914
- self?.navigatingPaths.remove(path)
915
- if let error {
916
- self?.logger.error("pushTemplate failed for \(path): \(error.localizedDescription)")
917
- }
918
- }
919
- }
920
-
921
- /// Switches to the tab that owns `path`, if any — a "view all" header or row
922
- /// pointing at a tab's own path belongs in the tab bar, not pushed on top of
923
- /// whichever tab the user tapped from (which would strand them one level deep
924
- /// in a list the bar already offers, with a Back button to a page they never
925
- /// came from). Any pushed stack is popped first, so the tab opens at its root.
926
- ///
927
- /// Returns false — and the caller pushes as usual — when `path` is no tab,
928
- /// when the root isn't the tab bar (the gate replaces it), or before iOS 17,
929
- /// which has no tab-selection API.
930
- @MainActor
931
- private func selectTab(owning path: String) -> Bool {
932
- guard #available(iOS 17.0, *),
933
- let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
934
- let tab = tabBar.templates.first(where: { getPath(from: $0) == path })
935
- else { return false }
936
- let isPushed = interfaceController.templates.count > 1
937
- // Already showing: the destination is where the user asked to be, so this
938
- // is handled — just don't animate a pop-and-select over it.
939
- if tabBar.selectedTemplate === tab, !isPushed { return true }
940
- logger.debug("Navigating to tab \(path) instead of pushing it")
941
- guard isPushed else {
942
- tabBar.select(tab)
943
- return true
944
- }
945
- interfaceController.safePopToRoot(animated: true) { popped, _ in
946
- // Selecting after a failed pop would leave the pushed stack over the
947
- // newly selected tab; the caller has already returned, so a failure just
948
- // leaves the user where they were.
949
- guard popped else { return }
950
- tabBar.select(tab)
951
- }
952
- return true
953
- }
954
-
955
- /// Builds an empty list template for content that is still resolving.
956
- /// On iOS 18.4+ it shows the system spinner (`showsSpinnerWhileEmpty`). On older
957
- /// iOS it shows the app-localized `carPlayLoadingTitle` as the centered empty
958
- /// state when configured; otherwise it stays blank rather than ship a
959
- /// hardcoded, un-localized "Loading…". The empty view is set at creation —
960
- /// the timing CarPlay renders reliably (see `replaceWithMessage`).
961
- private func makeLoadingTemplate(title: String?, path: String?) -> CPListTemplate {
962
- let template = CPListTemplate(title: title, sections: [])
963
- if let path {
964
- template.userInfo = ["path": path] as [String: Any]
965
- }
966
- if let loadingTitle = config.carPlayLoadingTitle {
967
- // Subtitle slot on purpose: the title slot renders at CarPlay's large
968
- // empty-state size, which is too heavy for a transient loading hint.
969
- template.emptyViewSubtitleVariants = [loadingTitle]
970
- }
971
- if #available(iOS 18.4, *) {
972
- template.showsSpinnerWhileEmpty = true
973
- }
974
- return template
975
- }
976
-
977
- /// Shows a centered empty/error message on a loading template. A *pushed*
978
- /// template is replaced (see `replaceWithMessage`); a *tab root* can't be
979
- /// popped, so its empty view is set in place (best-effort — a tab renders its
980
- /// empty view when selected).
981
- private func showMessage(on template: CPListTemplate, title: String, subtitle: String?) {
982
- if isTabRoot(template) {
983
- if #available(iOS 18.4, *) {
984
- template.showsSpinnerWhileEmpty = false
985
- }
986
- template.emptyViewTitleVariants = [title.isEmpty ? "Couldn't load" : title]
987
- template.emptyViewSubtitleVariants = subtitle.map { [$0] } ?? []
988
- template.updateSections([])
989
- } else {
990
- replaceWithMessage(template, title: title, subtitle: subtitle)
991
- }
992
- }
993
-
994
- /// Whether `template` is a tab's root template (a child of the tab bar). Such
995
- /// templates can't be popped, so the replace-by-pop strategy doesn't apply.
996
- private func isTabRoot(_ template: CPTemplate) -> Bool {
997
- guard let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate else { return false }
998
- return tabBar.templates.contains { $0 === template }
999
- }
1000
-
1001
- /// Replaces an on-screen loading template with a fresh list template showing a
1002
- /// centered empty-state message (empty result, or an error).
1003
- ///
1004
- /// We *replace* rather than mutate because CarPlay reliably renders a list
1005
- /// template's empty view only as its **initial** state at push time — changing
1006
- /// `emptyViewTitleVariants` / `showsSpinnerWhileEmpty` on an already-pushed
1007
- /// template is unreliable (it renders late, or not at all). The replacement
1008
- /// carries no `path`, so `loadContentIfNeeded` won't lazy-reload it; backing
1009
- /// out and re-tapping retries.
1010
- private func replaceWithMessage(_ loadingTemplate: CPListTemplate, title: String, subtitle: String?) {
1011
- // Only replace if the loading template is still the visible top template
1012
- // (the user may have backed out while the resolve was in flight).
1013
- guard interfaceController.topTemplate === loadingTemplate else { return }
1014
-
1015
- let message = CPListTemplate(title: loadingTemplate.title, sections: [])
1016
- message.emptyViewTitleVariants = [title.isEmpty ? "Couldn't load" : title]
1017
- message.emptyViewSubtitleVariants = subtitle.map { [$0] } ?? []
1018
-
1019
- interfaceController.safePop(animated: false) { [weak self] _, _ in
1020
- self?.interfaceController.safePush(message, animated: false)
1021
- }
1022
- }
1023
-
1024
- // MARK: - Content Change Handlers
1025
-
1026
- @MainActor
1027
- private func handleTabsChanged(_ tabs: [Track]) {
1028
- logger.debug("Tabs changed: \(tabs.count) tabs")
1029
- // Compare against the list showTabBar builds templates from, not the raw one.
1030
- let visibleTabs = Self.visibleTabs(tabs)
1031
- // Same tabs by path while ungated → only the tab-bar entries changed
1032
- // (e.g. titles after a locale switch): restamp them in place. No rebuild,
1033
- // so the selected tab and any pushed navigation stack survive. (Gate
1034
- // templates carry no path, so a gated tab bar never matches here.)
1035
- if !isGated,
1036
- let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
1037
- tabBar.templates.count == min(visibleTabs.count, CPTabBarTemplate.maximumTabCount),
1038
- zip(tabBar.templates, visibleTabs).allSatisfy({ getPath(from: $0) == $1.path })
1039
- {
1040
- for (template, tab) in zip(tabBar.templates, visibleTabs) {
1041
- applyTabBarEntry(to: template, for: tab)
1042
- }
1043
- pendingTabs = nil
1044
- return
1045
- }
1046
- // Rebuilding replaces the root template, tearing down any pushed
1047
- // navigation stack. If the user is browsing, defer until they're back at
1048
- // the tab bar (applied from templateDidAppear).
1049
- guard interfaceController.templates.count <= 1 else {
1050
- pendingTabs = tabs
1051
- return
1052
- }
1053
- pendingTabs = nil
1054
- Task {
1055
- await showTabBar(tabs: tabs)
1056
- }
1057
- }
1058
-
1059
- /// Applies a deferred tab change once the user has popped back to the tab
1060
- /// bar. Returns true if a rebuild was kicked off (the appeared template is
1061
- /// about to be replaced, so callers should skip further work on it).
1062
- @MainActor
1063
- fileprivate func applyPendingTabsIfAtRoot() -> Bool {
1064
- guard let tabs = pendingTabs, interfaceController.templates.count <= 1 else {
1065
- return false
1066
- }
1067
- pendingTabs = nil
1068
- Task {
1069
- await showTabBar(tabs: tabs)
1070
- }
1071
- return true
1072
- }
1073
-
1074
- @MainActor
1075
- private func handleContentChanged(_ content: ResolvedTrack?) {
1076
- // This callback fires when the main browser's content changes.
1077
- // For CarPlay-specific refreshes (e.g., favorites), use notifyContentChanged instead.
1078
- guard let content else { return }
1079
- refreshTemplatesForPath(content.path, with: content)
1080
- }
1081
-
1082
- @MainActor
1083
- private func handleActiveTrackChanged(_ event: PlaybackActiveTrackChangedEvent) {
1084
- logger.debug("handleActiveTrackChanged: \(event.lastTrack?.src ?? "nil") → \(event.track?.src ?? "nil")")
1085
- updatePlayingIndicators()
1086
- // Refresh per-track button state: favorite heart, Up Next availability,
1087
- // and the album line's pre-resolved destination.
1088
- nowPlayingManager.handleActiveTrackChanged()
1089
- }
1090
-
1091
- /// Updates the isPlaying state (and indicator location, which options can
1092
- /// change at runtime) on all list items based on the current active track.
1093
- @MainActor
1094
- fileprivate func updatePlayingIndicators() {
1095
- let location = playingIndicatorLocation
1096
- var templates: [CPListTemplate] = []
1097
-
1098
- if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate {
1099
- for template in tabBar.templates {
1100
- if let listTemplate = template as? CPListTemplate {
1101
- templates.append(listTemplate)
1102
- }
1103
- }
1104
- }
1105
-
1106
- if let topTemplate = interfaceController.topTemplate as? CPListTemplate,
1107
- !templates.contains(where: { $0 === topTemplate })
1108
- {
1109
- templates.append(topTemplate)
1110
- }
1111
-
1112
- for template in templates {
1113
- var templateChanged = false
1114
- for section in template.sections {
1115
- for item in section.items {
1116
- guard let listItem = item as? CPListItem,
1117
- let info = listItem.carPlayItemInfo,
1118
- info.identity != nil
1119
- else { continue }
1120
-
1121
- let isPlaying = isActiveTrack(identity: info.identity)
1122
- if listItem.isPlaying != isPlaying {
1123
- logger.debug(
1124
- "Updating isPlaying for \(info.identity ?? ""): \(listItem.isPlaying) → \(isPlaying)")
1125
- listItem.isPlaying = isPlaying
1126
- templateChanged = true
1127
- }
1128
- if listItem.playingIndicatorLocation != location {
1129
- listItem.playingIndicatorLocation = location
1130
- templateChanged = true
1131
- }
1132
- }
1133
- }
1134
- // Setting isPlaying on an already-displayed item does not reliably reach
1135
- // the CarPlay screen (rdar-known; the flag only renders when the item is
1136
- // pushed with it already set). Re-pushing the same sections re-serializes
1137
- // every item with its current flags — done only when something actually
1138
- // changed, so routine appear-time passes don't churn the display.
1139
- if templateChanged {
1140
- template.updateSections(template.sections)
1141
- }
1142
- }
1143
- }
1144
-
1145
- // MARK: - Public Content Notification
1146
-
1147
- /// Notifies CarPlay that content at the given path has changed and should be refreshed.
1148
- /// Called from HybridAudioBrowser.notifyContentChanged() to update CarPlay lists.
1149
- ///
1150
- /// - Parameter path: The path where content has changed (e.g., "/favorites")
1151
- @objc
1152
- public func notifyContentChanged(path: String) {
1153
- guard isStarted else { return }
1154
-
1155
- Task { @MainActor in
1156
- await refreshContentForPath(path)
1157
- }
1158
- }
1159
-
1160
- /// Fetches fresh content for a path and updates any matching CarPlay templates.
1161
- @MainActor
1162
- private func refreshContentForPath(_ path: String) async {
1163
- guard let audioBrowser else { return }
1164
-
1165
- logger.debug("Refreshing CarPlay content for path: \(path)")
1166
-
1167
- // Fetch fresh content (bypassing cache since content changed)
1168
- do {
1169
- let resolved = try await audioBrowser.browserManager.resolve(path, useCache: false)
1170
- refreshTemplatesForPath(path, with: resolved)
1171
- } catch {
1172
- logger.error("Failed to refresh content for \(path): \(error.localizedDescription)")
1173
- }
1174
- }
1175
-
1176
- /// Updates all CarPlay templates that are displaying the given path.
1177
- @MainActor
1178
- private func refreshTemplatesForPath(_ path: String, with content: ResolvedTrack) {
1179
- logger.debug("Content changed for path: \(path)")
1180
-
1181
- // Check if the root template is a tab bar and refresh matching tabs
1182
- if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate {
1183
- for template in tabBar.templates {
1184
- guard let listTemplate = template as? CPListTemplate,
1185
- let templatePath = getPath(from: listTemplate),
1186
- templatePath == path
1187
- else { continue }
1188
-
1189
- logger.info("Refreshing tab template for path: \(path)")
1190
- updateTemplate(listTemplate, with: content)
1191
- }
1192
- }
1193
-
1194
- // Check if any template in the navigation stack matches the changed path
1195
- // The top template is the currently visible one
1196
- if let topTemplate = interfaceController.topTemplate as? CPListTemplate,
1197
- let templatePath = getPath(from: topTemplate),
1198
- templatePath == path
1199
- {
1200
- logger.info("Refreshing top template for path: \(path)")
1201
- updateTemplate(topTemplate, with: content)
1202
- }
1203
- }
1204
-
1205
- /// Refreshes every currently-displayed template (tabs + navigation stack) by
1206
- /// re-fetching each path with the cache bypassed. Called when all content was
1207
- /// invalidated app-wide (e.g. a locale change).
1208
- @MainActor
1209
- public func invalidateAllContent() {
1210
- guard isStarted else { return }
1211
- Task { @MainActor in await refreshAllDisplayedTemplates() }
1212
- }
1213
-
1214
- @MainActor
1215
- private func refreshAllDisplayedTemplates() async {
1216
- guard let audioBrowser else { return }
1217
-
1218
- var templates: [CPListTemplate] = []
1219
- if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate {
1220
- for template in tabBar.templates {
1221
- if let listTemplate = template as? CPListTemplate {
1222
- templates.append(listTemplate)
1223
- }
1224
- }
1225
- }
1226
- for template in interfaceController.templates {
1227
- if let listTemplate = template as? CPListTemplate,
1228
- !templates.contains(where: { $0 === listTemplate })
1229
- {
1230
- templates.append(listTemplate)
1231
- }
1232
- }
1233
-
1234
- for template in templates {
1235
- guard let path = getPath(from: template) else { continue }
1236
- do {
1237
- let resolved = try await audioBrowser.browserManager.resolve(path, useCache: false)
1238
- updateTemplate(template, with: resolved)
1239
- } catch {
1240
- logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
1241
- }
1242
- }
1243
- }
1244
-
1245
- // MARK: - Gate
1246
-
1247
- /// Applies a gate state change: set or clear. The per-request chrome is
1248
- /// obtained at render time via `gateDecision(for:)`, so this just tears down
1249
- /// any pushed navigation and rebuilds the tab bar — `showTabBar` routes to the
1250
- /// gated build while a gate is active and to the normal build once cleared.
1251
- /// Custom Now Playing buttons (e.g. favorite) hide while gated and return on
1252
- /// clear.
1253
- @MainActor
1254
- private func handleGateChanged(_ active: Bool) {
1255
- isGated = active
1256
- let tabs = audioBrowser?.browserManager.getTabs() ?? []
1257
- if tabs.isEmpty, !active {
1258
- // Never had tabs (gate was up since before config) — full initial build.
1259
- Task { await buildInitialInterface() }
1260
- } else {
1261
- // Tear down any pushed navigation — gated content must not stay reachable
1262
- // behind the gate, and on clear we return to the root. (No-op at root.)
1263
- interfaceController.safePopToRoot(animated: false)
1264
- Task { await showTabBar(tabs: tabs) }
1265
- }
1266
- nowPlayingManager.setupNowPlayingButtons()
1267
- nowPlayingManager.updateNowPlayingButtonStates()
1268
- }
1269
-
1270
- /// Builds the gate page for one tab-bar slot, carrying the tab's entry so
1271
- /// the tab bar itself stays familiar.
1272
- ///
1273
- /// A CPListTemplate, NOT a CPInformationTemplate: CarPlay enforces
1274
- /// per-entitlement template allowances at runtime, and the audio
1275
- /// entitlement does not include the information template — handing one to
1276
- /// CPTabBarTemplate throws an unhandled ObjC exception (crash at init).
1277
- ///
1278
- /// Within a list, tab children never show navigation-bar buttons (the tab
1279
- /// bar owns that chrome), and the centered empty view renders only when the
1280
- /// list has NO rows. The gate is a plain centered message — `title` as the
1281
- /// empty-view title and `message` as its subtitle, where newlines collapse
1282
- /// to spaces (the "variants" are width alternatives, not lines). No action
1283
- /// button: a gate withholds content; the consumer surfaces any "subscribe"
1284
- /// affordance in its own UI, not on the car surface.
1285
- private func makeGateTemplate(gate gateChrome: Gate?, tab: Track?) -> CPListTemplate {
1286
- // A gated decision with neither an override nor a stored default chrome
1287
- // (resolver-only `true`) falls back to the built-in minimal gate.
1288
- let gate = gateChrome ?? HybridAudioBrowser.builtInGate
1289
- let template = CPListTemplate(title: gate.title, sections: [])
1290
- // Set at creation — the timing CarPlay renders the empty view reliably
1291
- // (see replaceWithMessage).
1292
- template.emptyViewTitleVariants = [gate.title]
1293
- if let message = gate.message, !message.isEmpty {
1294
- template.emptyViewSubtitleVariants = [
1295
- message.replacingOccurrences(of: "\n", with: " "),
1296
- ]
1297
- }
1298
- // Marks the page as a gate (vs. a content tab, which carries a `path`),
1299
- // so the lazy-loader and refresh paths never try to fill it.
1300
- template.userInfo = ["gate": true] as [String: Any]
1301
- if let tab {
1302
- applyTabBarEntry(to: template, for: tab)
1303
- }
1304
- return template
1305
- }
1306
-
1307
- // MARK: - Error Handling
1308
-
1309
- /// Shows a navigation error using CPActionSheetTemplate.
1310
- /// - Parameters:
1311
- /// - error: The NavigationError to display
1312
- /// - path: The path that was being navigated to when the error occurred
1313
- private func showNavigationError(_ error: NavigationError, path: String) {
1314
- let defaultFormatted = error.defaultFormatted()
1315
-
1316
- // Check if custom formatter is configured
1317
- logger.debug("showNavigationError: formatNavigationError is \(self.config.formatNavigationError != nil ? "set" : "nil")")
1318
- if let formatter = config.formatNavigationError {
1319
- logger.debug("Calling formatNavigationError callback...")
1320
- // Call the JS callback and handle result
1321
- let params = FormatNavigationErrorParams(
1322
- error: error, defaultFormatted: defaultFormatted, path: path,
1323
- kind: NavigationErrorKind(path: path),
1324
- )
1325
- // The result callbacks are @Sendable and hop back explicitly: Nitro resolves
1326
- // its promises synchronously on the JS thread, so a plain closure would
1327
- // inherit this type's MainActor isolation and trap on entry under Swift 6's
1328
- // dynamic isolation check — and would otherwise drive UIKit off the main
1329
- // thread. Same pattern as HybridAudioBrowser.handleNavigationError and
1330
- // NowPlayingUpdater.render.
1331
- formatter(params)
1332
- .then { @Sendable [weak self] customDisplay in
1333
- let description = String(describing: customDisplay)
1334
- Task { @MainActor in
1335
- guard let self else { return }
1336
- self.logger.debug("formatNavigationError returned: \(description)")
1337
- self.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
1338
- }
1339
- }
1340
- .catch { @Sendable [weak self] callbackError in
1341
- let message = callbackError.localizedDescription
1342
- Task { @MainActor in
1343
- guard let self else { return }
1344
- self.logger.error("formatNavigationError failed: \(message)")
1345
- // On error, fall back to defaults
1346
- self.presentErrorActionSheet(customDisplay: defaultFormatted)
1347
- }
1348
- }
1349
- } else {
1350
- presentErrorActionSheet(customDisplay: defaultFormatted)
1351
- }
1352
- }
1353
-
1354
- /// Presents the error action sheet with the given display info.
1355
- /// - Parameter customDisplay: The formatted error to display
1356
- private func presentErrorActionSheet(customDisplay: FormattedNavigationError) {
1357
- // If another template is already presented, dismiss it first
1358
- if interfaceController.presentedTemplate != nil {
1359
- interfaceController.safeDismiss(animated: false) { [weak self] _, _ in
1360
- self?.showErrorActionSheet(customDisplay: customDisplay)
1361
- }
1362
- } else {
1363
- showErrorActionSheet(customDisplay: customDisplay)
1364
- }
1365
- }
1366
-
1367
- /// Actually shows the error action sheet (called after safety checks)
1368
- private func showErrorActionSheet(customDisplay: FormattedNavigationError) {
1369
- // OK action - dismiss the action sheet (use system-localized "OK")
1370
- let okTitle = Bundle(for: UIAlertController.self).localizedString(forKey: "OK", value: "OK", table: nil)
1371
- let ok = CPAlertAction(title: okTitle, style: .cancel) { [weak self] _ in
1372
- self?.interfaceController.safeDismiss(animated: true)
1373
- }
1374
-
1375
- let actionSheet = CPActionSheetTemplate(
1376
- title: customDisplay.title,
1377
- message: customDisplay.message,
1378
- actions: [ok],
1379
- )
1380
-
1381
- interfaceController.safePresent(actionSheet, animated: true)
1382
- }
1383
-
1384
- /// Shows an initialization failure as the root template — a centered empty
1385
- /// view formatted via the app's `formatNavigationError` (path "/"), like every
1386
- /// other browse failure. The empty view renders reliably here because it's the
1387
- /// template's initial state at set-root time.
1388
- ///
1389
- /// Formatting awaits the app's `formatNavigationError` callback, so a tab-bar
1390
- /// build can start and paint during the hop — this error root must not
1391
- /// clobber it. Captured here rather than passed in: the `showTabBar` /
1392
- /// `showGatedTabBar` callers hold the newest generation when they call, so
1393
- /// capturing is equivalent for them and covers the others for free.
1394
- ///
1395
- /// Not airtight for `buildInitialInterface`'s two callers: that function
1396
- /// doesn't participate in `gateBuildGeneration` and suspends at `queryTabs()`,
1397
- /// so a build that superseded it *before* this was entered isn't caught.
1398
- /// Closing that needs a generation captured at `buildInitialInterface` entry.
1399
- @MainActor
1400
- private func showRootNavigationError(_ navError: NavigationError) async {
1401
- let generation = gateBuildGeneration
1402
- let formatted = await formattedNavigationError(navError, path: "/")
1403
- guard gateBuildGeneration == generation else { return }
1404
- let template = CPListTemplate(title: nil, sections: [])
1405
- template.emptyViewTitleVariants = [formatted.title]
1406
- template.emptyViewSubtitleVariants = formatted.message.flatMap { $0.isEmpty ? nil : [$0] } ?? []
1407
- interfaceController.safeSetRoot(template, animated: true)
1408
- }
1409
89
  }
1410
90
 
1411
- // MARK: - CPInterfaceControllerDelegate
1412
-
1413
- /// Separate delegate class to avoid exposing CPInterfaceControllerDelegate to Obj-C header
91
+ /// Keeps `CPInterfaceControllerDelegate` out of the Objective-C header.
1414
92
  private final class InterfaceControllerDelegate: NSObject, CPInterfaceControllerDelegate {
1415
93
  private weak var controller: RNABCarPlayController?
1416
94
 
@@ -1420,88 +98,10 @@ private final class InterfaceControllerDelegate: NSObject, CPInterfaceController
1420
98
  }
1421
99
 
1422
100
  func templateDidAppear(_ aTemplate: CPTemplate, animated _: Bool) {
1423
- // Popping back from Now Playing surfaces the tab bar (not a list), so the
1424
- // guard must let both through — the playing indicator was toggled while
1425
- // the list was covered, and this appearance is the moment to repaint it.
1426
- guard aTemplate is CPListTemplate || aTemplate is CPTabBarTemplate else { return }
1427
-
1428
- // A tab change deferred while the user was browsing replaces the whole
1429
- // tab bar — no point updating a template that's about to be discarded.
1430
- if controller?.applyPendingTabsIfAtRoot() == true { return }
1431
-
1432
- // Update playing indicators when navigating back to a list template
1433
- controller?.updatePlayingIndicators()
1434
-
1435
- // Lazy load content for tabs that haven't been loaded yet
1436
- if let listTemplate = aTemplate as? CPListTemplate {
1437
- controller?.loadContentIfNeeded(for: listTemplate)
1438
- }
101
+ controller?.session?.templateDidAppear(aTemplate)
1439
102
  }
1440
103
 
1441
104
  func templateDidDisappear(_ aTemplate: CPTemplate, animated _: Bool) {
1442
- // The complement of the appear hook: whether popping Now Playing reports
1443
- // "tab bar appeared" or only "now playing disappeared" is a platform
1444
- // detail — cover both so the revealed list always repaints its indicator.
1445
- guard aTemplate is CPNowPlayingTemplate else { return }
1446
- controller?.updatePlayingIndicators()
1447
- }
1448
- }
1449
-
1450
- // MARK: - Lazy Loading
1451
-
1452
- private extension RNABCarPlayController {
1453
- /// Loads content for a template if it hasn't been loaded yet (lazy loading for tabs)
1454
- func loadContentIfNeeded(for template: CPListTemplate) {
1455
- // Get path from userInfo
1456
- guard let path = getPath(from: template) else { return }
1457
-
1458
- // The template is now on screen, so a queued duplicate navigation to it is
1459
- // no longer a concern (the top-template check will catch any further taps).
1460
- navigatingPaths.remove(path)
1461
-
1462
- // Skip if already has content (single-flight in loadContent guards re-entry).
1463
- guard template.sections.isEmpty else { return }
1464
-
1465
- logger.debug("Lazy loading content for tab: \(path)")
1466
-
1467
- Task {
1468
- await loadContent(for: path, into: template)
1469
- }
1470
- }
1471
- }
1472
-
1473
- // MARK: - NavigationError Shorthand
1474
-
1475
- extension NavigationError {
1476
- /// A code-only error: the user-facing copy comes from the app's
1477
- /// formatNavigationError (ADR 0001), so no message travels with it.
1478
- static func code(_ code: NavigationErrorType) -> NavigationError {
1479
- NavigationError(code: code, message: "", statusCode: nil, statusCodeSuccess: nil)
1480
- }
1481
- }
1482
-
1483
- // MARK: - UIImage Resize
1484
-
1485
- extension UIImage {
1486
- /// Draws the image centered within the target size, maintaining aspect
1487
- /// ratio, at the given display scale (the car screen's, not the phone's).
1488
- func resized(to targetSize: CGSize, scale: CGFloat) -> UIImage? {
1489
- UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
1490
- defer { UIGraphicsEndImageContext() }
1491
-
1492
- // Scale to fit while maintaining aspect ratio. Named `fitScale` so it does
1493
- // not shadow the `scale` parameter (the display scale) it sits next to.
1494
- let widthRatio = targetSize.width / size.width
1495
- let heightRatio = targetSize.height / size.height
1496
- let fitScale = min(widthRatio, heightRatio)
1497
-
1498
- let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
1499
- let origin = CGPoint(
1500
- x: (targetSize.width - scaledSize.width) / 2,
1501
- y: (targetSize.height - scaledSize.height) / 2,
1502
- )
1503
-
1504
- draw(in: CGRect(origin: origin, size: scaledSize))
1505
- return UIGraphicsGetImageFromCurrentImageContext()?.withRenderingMode(.alwaysTemplate)
105
+ controller?.session?.templateDidDisappear(aTemplate)
1506
106
  }
1507
107
  }