react-native-audio-browser 0.2.0-next.27 → 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.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /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
|
-
///
|
|
7
|
-
///
|
|
8
|
-
///
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
///
|
|
92
|
-
private var
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
234
|
-
currentRun = nil
|
|
235
|
-
|
|
74
|
+
guard run != nil else { return }
|
|
236
75
|
logger.info("Stopping CarPlay controller")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|