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.
Files changed (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,114 @@
1
+ import CarPlay
2
+ import NitroModules
3
+
4
+ extension CarPlaySession {
5
+ /// A row or tile was tapped. CarPlay is released at once, so the list never locks: a browse
6
+ /// pushes its destination and fills it in, and playback owns its loading state on Now Playing.
7
+ func select(_ track: Track, completion: @escaping () -> Void) {
8
+ logger.info("Selected track: \(track.title)")
9
+ // A tap queued before `end()`: the next run owns the screen.
10
+ guard !ended else {
11
+ completion()
12
+ return
13
+ }
14
+
15
+ if track.src != nil, let identity = track.identity, player?.currentTrack?.identity == identity {
16
+ try? audioBrowser.play()
17
+ nowPlaying.showNowPlaying()
18
+ completion()
19
+ return
20
+ }
21
+
22
+ // A playable track always lands on Now Playing, so it is pushed now with the tapped
23
+ // track's fields: the queue expansion and media resolve may both hit the network.
24
+ if track.src != nil {
25
+ audioBrowser.engine?.loadNowPlayingMetadata(for: track)
26
+ nowPlaying.prepare(for: track)
27
+ nowPlaying.showNowPlaying()
28
+ }
29
+ completion()
30
+
31
+ spawn { session in
32
+ guard let player = session.player else { return }
33
+ let result = await session.trackSelector.select(track: track, player: player)
34
+ guard !Task.isCancelled else {
35
+ if track.src != nil { session.restoreNowPlaying() }
36
+ return
37
+ }
38
+ switch result {
39
+ case let .play(intent):
40
+ session.player?.execute(intent)
41
+ session.nowPlaying.showNowPlaying(popToFront: false)
42
+ case .intercepted:
43
+ session.nowPlaying.showNowPlaying(popToFront: false)
44
+ case let .browse(path):
45
+ await session.navigate(to: path, title: track.title)
46
+ case .none:
47
+ break
48
+ }
49
+ }
50
+ }
51
+
52
+ /// Puts the current track back on the now-playing surface, over a tapped one that won't play.
53
+ private func restoreNowPlaying() {
54
+ guard let engine = audioBrowser.engine else { return }
55
+ if let current = player?.currentTrack {
56
+ engine.loadNowPlayingMetadata(for: current)
57
+ engine.nowPlayingUpdater.render()
58
+ } else {
59
+ engine.clearNowPlaying()
60
+ }
61
+ }
62
+
63
+ /// Pushes `path`'s page as an empty, spinning template that fills in once it appears; or the
64
+ /// Browse Gate's page when the gate gates it; or selects the tab that is that path.
65
+ func navigate(to path: String, title: String) async {
66
+ // A push lands asynchronously, so a double tap is caught by `navigatingPaths` until the
67
+ // pushed template appears.
68
+ guard interfaceController.topTemplate?.carPlayInfo?.path != path, !navigatingPaths.contains(path) else { return }
69
+ navigatingPaths.insert(path)
70
+ var pushing = false
71
+ defer { if !pushing { navigatingPaths.remove(path) } }
72
+
73
+ let chrome = audioBrowser.browseGate.isActive ? await gate(.browse, path: path) : nil
74
+ guard !Task.isCancelled else { return }
75
+ let template: CPTemplate
76
+ if let chrome {
77
+ template = makeGatePage(chrome, path: path, tab: nil)
78
+ } else if selectTab(owning: path) {
79
+ return
80
+ } else {
81
+ template = CPListTemplate.loading(title: title, path: path, loadingTitle: config.carPlayLoadingTitle)
82
+ }
83
+
84
+ pushing = true
85
+ interfaceController.safePush(template, animated: true) { [weak self] pushed, error in
86
+ guard !pushed else { return }
87
+ // It will never appear to clear the guard (e.g. at the depth limit).
88
+ self?.navigatingPaths.remove(path)
89
+ if let error { self?.logger.error("pushTemplate failed for \(path): \(error.localizedDescription)") }
90
+ }
91
+ }
92
+
93
+ /// Switches to the tab whose path this is, popping any pushed stack first, instead of pushing
94
+ /// a second copy of the tab into another tab's stack. False when no tab has the path, when
95
+ /// the root is not a tab bar, or before iOS 17, which cannot select a tab.
96
+ private func selectTab(owning path: String) -> Bool {
97
+ guard #available(iOS 17.0, *),
98
+ let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
99
+ let tab = tabBar.templates.first(where: { $0.contentPath == path })
100
+ else { return false }
101
+ let isPushed = interfaceController.templates.count > 1
102
+ if tabBar.selectedTemplate === tab, !isPushed { return true }
103
+ guard isPushed else {
104
+ tabBar.select(tab)
105
+ return true
106
+ }
107
+ interfaceController.safePopToRoot(animated: true) { popped, _ in
108
+ // After a failed pop the selection would land under the pushed stack.
109
+ guard popped else { return }
110
+ tabBar.select(tab)
111
+ }
112
+ return true
113
+ }
114
+ }
@@ -0,0 +1,92 @@
1
+ import CarPlay
2
+ import NitroModules
3
+
4
+ extension CarPlaySession {
5
+ /// Loads a content template's page when it first appears, the moment CarPlay applies updates
6
+ /// (an update made straight after a push is dropped).
7
+ func loadContentIfNeeded(for template: CPListTemplate) {
8
+ guard let path = template.contentPath, template.sections.isEmpty else { return }
9
+ spawn { await $0.loadContent(for: path, into: template) }
10
+ }
11
+
12
+ /// Resolves `path` into a loading template, showing a centered message when the page is
13
+ /// empty, fails, or outlasts `resolveTimeout`.
14
+ func loadContent(for path: String, into template: CPListTemplate) async {
15
+ let templateId = ObjectIdentifier(template)
16
+ guard !loadingTemplates.contains(templateId) else { return }
17
+ loadingTemplates.insert(templateId)
18
+ defer { loadingTemplates.remove(templateId) }
19
+
20
+ let timeout = resolveTimeout
21
+ let watchdog = Task { @MainActor [weak self] in
22
+ do { try await Task.sleep(for: timeout) } catch { return }
23
+ guard let self, template.sections.isEmpty else { return }
24
+ logger.error("loadContent: resolve timed out for \(path)")
25
+ await showNavigationError(
26
+ NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
27
+ )
28
+ }
29
+ // Cancelled as soon as the resolve settles: formatting its error is a JS round trip the
30
+ // timeout must not overwrite.
31
+ defer { watchdog.cancel() }
32
+
33
+ do {
34
+ let resolved: ResolvedTrack
35
+ do {
36
+ resolved = try await audioBrowser.browserManager.resolve(path, useCache: true)
37
+ } catch {
38
+ watchdog.cancel()
39
+ throw error
40
+ }
41
+ watchdog.cancel()
42
+ guard !Task.isCancelled else { return }
43
+ // An assistant cell is content in itself, even with no rows; a page of empty sections is
44
+ // empty (ADR 0001).
45
+ let hasChildren = resolved.normalizedSections?.contains { !$0.children.isEmpty } ?? false
46
+ if hasChildren || resolved.carPlaySiriListButton != nil {
47
+ updateTemplate(template, with: resolved)
48
+ } else {
49
+ await showNavigationError(.code(.emptyContent), path: path, on: template)
50
+ }
51
+ } catch {
52
+ logger.error("Failed to load content for \(path): \(error.localizedDescription)")
53
+ await showNavigationError(NavigationError.from(error), path: path, on: template)
54
+ }
55
+ }
56
+
57
+ func updateTemplate(_ template: CPListTemplate, with resolvedTrack: ResolvedTrack) {
58
+ template.updateSections(listItemFactory.createSections(from: resolvedTrack))
59
+ template.assistantCellConfiguration = resolvedTrack.carPlaySiriListButton.map { position in
60
+ CPAssistantCellConfiguration(
61
+ position: position == .top ? .top : .bottom, visibility: .always, assistantAction: .playMedia,
62
+ )
63
+ }
64
+ }
65
+
66
+ /// Shows a navigation error, as the app's `formatNavigationError` formats it, as the
67
+ /// template's centered message (ADR 0001).
68
+ private func showNavigationError(_ error: NavigationError, path: String, on template: CPListTemplate) async {
69
+ let formatted = await audioBrowser.browserManager.formatted(error, path: path)
70
+ guard !Task.isCancelled else { return }
71
+ let title = formatted.title.isEmpty ? "Couldn't load" : formatted.title
72
+ if isTabRoot(template) {
73
+ // A tab root cannot be popped, so its message is set in place; it renders when the tab
74
+ // is selected.
75
+ if #available(iOS 18.4, *) {
76
+ template.showsSpinnerWhileEmpty = false
77
+ }
78
+ template.setMessage(title, subtitle: formatted.message)
79
+ template.updateSections([])
80
+ } else {
81
+ // A pushed template's empty view renders reliably only as its initial state, so it is
82
+ // replaced. The message has no path: backing out and re-tapping retries.
83
+ interfaceController.safeReplaceTop(
84
+ template, with: CPListTemplate.message(title, subtitle: formatted.message, listTitle: template.title), animated: false,
85
+ )
86
+ }
87
+ }
88
+
89
+ private func isTabRoot(_ template: CPTemplate) -> Bool {
90
+ (interfaceController.rootTemplate as? CPTabBarTemplate)?.templates.contains { $0 === template } ?? false
91
+ }
92
+ }
@@ -0,0 +1,43 @@
1
+ import CarPlay
2
+ import NitroModules
3
+
4
+ extension CarPlaySession {
5
+ /// Re-resolves `path`, bypassing the cache, into every displayed template showing it.
6
+ func refreshContent(for path: String) async {
7
+ do {
8
+ let resolved = try await audioBrowser.browserManager.resolve(path, useCache: false)
9
+ guard !Task.isCancelled else { return }
10
+ refreshDisplayedTemplates(for: path, with: resolved)
11
+ } catch {
12
+ logger.error("Failed to refresh content for \(path): \(error.localizedDescription)")
13
+ }
14
+ }
15
+
16
+ func refreshDisplayedTemplates(for path: String, with content: ResolvedTrack) {
17
+ for template in displayedListTemplates() where template.contentPath == path {
18
+ updateTemplate(template, with: content)
19
+ }
20
+ }
21
+
22
+ /// Re-resolves every displayed template at once, bypassing the cache: all content was
23
+ /// invalidated (e.g. a locale change).
24
+ func refreshAllDisplayedTemplates() async {
25
+ let browserManager = audioBrowser.browserManager
26
+ let loads = displayedListTemplates().compactMap { template in
27
+ template.contentPath.map { path in
28
+ (template, Task<ResolvedTrack?, Never> { @MainActor [logger] in
29
+ do {
30
+ return try await browserManager.resolve(path, useCache: false)
31
+ } catch {
32
+ logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
33
+ return nil
34
+ }
35
+ })
36
+ }
37
+ }
38
+ for load in loads {
39
+ guard let resolved = await load.1.value, !Task.isCancelled else { continue }
40
+ updateTemplate(load.0, with: resolved)
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,193 @@
1
+ import CarPlay
2
+ import NitroModules
3
+
4
+ extension CarPlaySession {
5
+ /// The tabs the tab bar shows: a disabled tab hides, as `CPTabBarTemplate` has no disabled
6
+ /// state (Android Auto filters the same way).
7
+ static func visibleTabs(_ tabs: [Track]) -> [Track] {
8
+ tabs.filter { $0.disabled != true }
9
+ }
10
+
11
+ func buildInitialInterface() async {
12
+ let build = beginRootBuild()
13
+ if let tabs = audioBrowser.browserManager.tabs, !tabs.isEmpty {
14
+ await showTabBar(tabs)
15
+ return
16
+ }
17
+ // The first loading root predates the config; re-show it with the app's loading title.
18
+ if let loadingTitle = config.carPlayLoadingTitle {
19
+ interfaceController.safeSetRoot(CPListTemplate.loading(title: nil, path: nil, loadingTitle: loadingTitle), animated: false)
20
+ }
21
+ do {
22
+ let tabs = try await audioBrowser.browserManager.queryTabs()
23
+ guard isCurrent(build) else { return }
24
+ await showTabBar(tabs)
25
+ } catch {
26
+ logger.error("Failed to query tabs: \(error.localizedDescription)")
27
+ await showRootError(NavigationError.from(error), build: build)
28
+ }
29
+ }
30
+
31
+ /// Builds the tab bar. While the Browse Gate is active each tab is decided on its own: a
32
+ /// gated tab becomes its gate page, an allowed one shows content.
33
+ func showTabBar(_ allTabs: [Track]) async {
34
+ let build = beginRootBuild()
35
+ builtTabs = allTabs
36
+ let tabs = Array(Self.visibleTabs(allTabs).prefix(CPTabBarTemplate.maximumTabCount))
37
+ let gated = audioBrowser.browseGate.isActive
38
+
39
+ // A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
40
+ // otherwise it is the empty-content error, like any other failure (ADR 0001).
41
+ guard !tabs.isEmpty else {
42
+ if gated, let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: .browse, path: nil, search: nil)) {
43
+ guard isCurrent(build) else { return }
44
+ audioBrowser.onGate(GateEvent(reason: .browse))
45
+ interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
46
+ } else {
47
+ await showRootError(.code(.emptyContent), build: build)
48
+ }
49
+ return
50
+ }
51
+
52
+ let chromes = gated ? await gates(for: tabs) : tabs.map { _ in nil }
53
+ guard isCurrent(build) else { return }
54
+ for _ in chromes.compactMap(\.self) {
55
+ audioBrowser.onGate(GateEvent(reason: .browse))
56
+ }
57
+ let templates = zip(tabs, chromes).map { tab, chrome in
58
+ chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
59
+ }
60
+
61
+ if gated, let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
62
+ tabBar.templates.count == templates.count
63
+ {
64
+ // An equal-count swap keeps the selected tab.
65
+ tabBar.updateTemplates(templates)
66
+ } else {
67
+ interfaceController.safeSetRoot(CPTabBarTemplate(templates: templates), animated: true)
68
+ }
69
+
70
+ // Tabs fill lazily on first appearance. The first one is on screen already, and a template
71
+ // swapped in by `updateTemplates` may never report appearing.
72
+ let eager = gated ? templates : Array(templates.prefix(1))
73
+ for template in eager {
74
+ guard let path = template.contentPath else { continue }
75
+ await loadContent(for: path, into: template)
76
+ }
77
+ }
78
+
79
+ /// The tabs changed: restamp their entries in place when the paths are the same, else
80
+ /// rebuild, once the user is back at the root.
81
+ func tabsChanged(_ tabs: [Track]) {
82
+ guard !TabBarEntries.same(builtTabs, tabs) else { return }
83
+ let visible = Self.visibleTabs(tabs)
84
+ if !audioBrowser.browseGate.isActive,
85
+ let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
86
+ tabBar.templates.count == min(visible.count, CPTabBarTemplate.maximumTabCount),
87
+ zip(tabBar.templates, visible).allSatisfy({ $0.contentPath == $1.path })
88
+ {
89
+ for (template, tab) in zip(tabBar.templates, visible) {
90
+ applyTabBarEntry(to: template, for: tab)
91
+ }
92
+ builtTabs = tabs
93
+ needsTabRebuild = false
94
+ return
95
+ }
96
+ needsTabRebuild = true
97
+ _ = rebuildTabsIfAtRoot()
98
+ }
99
+
100
+ /// Rebuilds the tab bar if a rebuild is due and the user is at the root. True when it did,
101
+ /// so the caller can skip work on a template about to be replaced.
102
+ func rebuildTabsIfAtRoot() -> Bool {
103
+ guard needsTabRebuild, interfaceController.templates.count <= 1 else { return false }
104
+ needsTabRebuild = false
105
+ spawn { await $0.showTabBar($0.audioBrowser.browserManager.tabs ?? []) }
106
+ return true
107
+ }
108
+
109
+ /// The gate was set or cleared: pushed content must not stay reachable behind it, and a
110
+ /// clear returns to the root.
111
+ func gateChanged() {
112
+ let tabs = audioBrowser.browserManager.tabs ?? []
113
+ if tabs.isEmpty, !audioBrowser.browseGate.isActive {
114
+ spawn { await $0.buildInitialInterface() }
115
+ } else {
116
+ interfaceController.safePopToRoot(animated: false)
117
+ spawn { await $0.showTabBar(tabs) }
118
+ }
119
+ nowPlaying.refresh()
120
+ }
121
+
122
+ /// The chrome to show if the Browse Gate gates this request, reporting it to JS; nil when the
123
+ /// request is allowed.
124
+ func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
125
+ let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
126
+ if chrome != nil { audioBrowser.onGate(GateEvent(reason: reason)) }
127
+ return chrome
128
+ }
129
+
130
+ /// One decision per tab, made concurrently and returned in tab order. Reports nothing: the
131
+ /// caller fires `onGate` once it knows its build is still current.
132
+ private func gates(for tabs: [Track]) async -> [Gate?] {
133
+ let browseGate = audioBrowser.browseGate
134
+ let decisions = tabs.map { tab in
135
+ Task<Gate?, Never> { @MainActor in
136
+ await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
137
+ }
138
+ }
139
+ var chromes: [Gate?] = []
140
+ for decision in decisions {
141
+ await chromes.append(decision.value)
142
+ }
143
+ return chromes
144
+ }
145
+
146
+ /// A tab's content shell, which loads when it first appears.
147
+ private func makeTabTemplate(for tab: Track) -> CPListTemplate {
148
+ let template = CPListTemplate.loading(title: tab.title, path: tab.path, loadingTitle: config.carPlayLoadingTitle)
149
+ applyTabBarEntry(to: template, for: tab)
150
+ return template
151
+ }
152
+
153
+ /// The gate page: a list template, because the audio entitlement does not allow
154
+ /// `CPInformationTemplate` (the tab bar raises on one), showing the chrome as its centered
155
+ /// empty view. No action button: a gate withholds content, and the app offers any
156
+ /// "subscribe" in its own UI.
157
+ func makeGatePage(_ chrome: Gate, path: String?, tab: Track?) -> CPListTemplate {
158
+ let template = CPListTemplate.message(
159
+ chrome.title, subtitle: chrome.message, listTitle: chrome.title,
160
+ info: CarPlayTemplateInfo(path: path, kind: .gate),
161
+ )
162
+ if let tab { applyTabBarEntry(to: template, for: tab) }
163
+ return template
164
+ }
165
+
166
+ /// Stamps a template's tab-bar title and image: an SF Symbol, loaded artwork, or none. Tab
167
+ /// icons are 24 pt (CarPlay Developer Guide).
168
+ func applyTabBarEntry(to template: CPTemplate, for track: Track) {
169
+ template.tabTitle = track.title
170
+ if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
171
+ let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
172
+ if let image = imageLoader.sfSymbolImage(symbolName) {
173
+ template.tabImage = image
174
+ }
175
+ } else if track.artwork != nil || track.artworkSource != nil {
176
+ imageLoader.loadArtwork(for: track, size: CGSize(width: 24, height: 24)) { [weak template] image in
177
+ Task { @MainActor in
178
+ if let image { template?.tabImage = image }
179
+ }
180
+ }
181
+ } else {
182
+ // A restamp must not leave an old icon on a now title-only tab.
183
+ template.tabImage = nil
184
+ }
185
+ }
186
+
187
+ /// Shows a navigation error as the root: the app's formatting of it at path "/" (ADR 0001).
188
+ func showRootError(_ error: NavigationError, build: UInt) async {
189
+ let formatted = await audioBrowser.browserManager.formatted(error, path: "/")
190
+ guard isCurrent(build) else { return }
191
+ interfaceController.safeSetRoot(CPListTemplate.message(formatted.title, subtitle: formatted.message), animated: true)
192
+ }
193
+ }
@@ -0,0 +1,209 @@
1
+ import CarPlay
2
+ import NitroModules
3
+ import os.log
4
+
5
+ /// One CarPlay run, from the moment the player and a configured browser exist until the scene
6
+ /// stops or the bridge is replaced. It owns its subscriptions and tasks: `end()` cancels them,
7
+ /// so nothing a stopped run started can paint over the next one.
8
+ @MainActor
9
+ final class CarPlaySession {
10
+ let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlaySession")
11
+
12
+ let interfaceController: CPInterfaceController
13
+ let audioBrowser: HybridAudioBrowser
14
+ /// Read on each use: a re-setup replaces the bridge's player.
15
+ var player: Player? { audioBrowser.getPlayer() }
16
+ let trackSelector: TrackSelector
17
+ let imageLoader: CarPlayImageLoader
18
+ let listItemFactory: CarPlayListItemFactory
19
+ private(set) var nowPlaying: CarPlayNowPlayingManager!
20
+
21
+ private var subscriptions: [Subscription] = []
22
+ private var tasks: [UUID: Task<Void, Never>] = [:]
23
+ private(set) var ended = false
24
+
25
+ /// Paths whose pushed template has not appeared yet; guards a double tap, since
26
+ /// `topTemplate` only shows a push once it lands.
27
+ var navigatingPaths: Set<String> = []
28
+
29
+ /// Templates with a content load in flight, so a re-appearing one doesn't start another.
30
+ var loadingTemplates: Set<ObjectIdentifier> = []
31
+
32
+ /// Set when the tabs change while the user is browsing: rebuilding the tab bar would tear
33
+ /// down the pushed stack, so it waits until they are back at the root.
34
+ var needsTabRebuild = false
35
+
36
+ /// The tabs the current root was built from; their change event, which the browser delivers
37
+ /// after the build has begun, has nothing left to change.
38
+ var builtTabs: [Track]?
39
+
40
+ /// Bumped by every root build (initial interface, tab bar, root error). A build that
41
+ /// suspended and finds it bumped has been superseded, and leaves the root alone.
42
+ private var rootBuildGeneration: UInt = 0
43
+
44
+ /// How long a destination may spin before it shows an error.
45
+ let resolveTimeout: Duration = .seconds(15)
46
+
47
+ var config: BrowserConfig {
48
+ audioBrowser.browserManager.config
49
+ }
50
+
51
+ init(interfaceController: CPInterfaceController, audioBrowser: HybridAudioBrowser) {
52
+ self.interfaceController = interfaceController
53
+ self.audioBrowser = audioBrowser
54
+ trackSelector = TrackSelector(browserManager: audioBrowser.browserManager)
55
+ imageLoader = CarPlayImageLoader(
56
+ carTraitCollection: interfaceController.carTraitCollection,
57
+ browserManager: audioBrowser.browserManager,
58
+ )
59
+ listItemFactory = CarPlayListItemFactory(
60
+ isActiveTrack: { [weak audioBrowser] identity in
61
+ identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
62
+ },
63
+ )
64
+ listItemFactory.imageLoader = imageLoader
65
+ nowPlaying = CarPlayNowPlayingManager(
66
+ interfaceController: interfaceController,
67
+ audioBrowser: audioBrowser,
68
+ listItem: { [weak self] track, handler in
69
+ self?.listItemFactory.createListItem(for: track, handler: handler) ?? CPListItem(text: track.title, detailText: nil)
70
+ },
71
+ navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
72
+ )
73
+ listItemFactory.onItemSelected = { [weak self] track, completion in self?.select(track, completion: completion) }
74
+ listItemFactory.onNavigate = { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } }
75
+ listItemFactory.playingIndicatorLocation = playingIndicatorLocation
76
+ }
77
+
78
+ func start() {
79
+ let browserManager = audioBrowser.browserManager
80
+ subscriptions = [
81
+ browserManager.tabsChanged.subscribeOnMain { [weak self] in self?.tabsChanged($0) },
82
+ browserManager.contentChanged.subscribeOnMain { [weak self] content in
83
+ guard let content else { return }
84
+ self?.refreshDisplayedTemplates(for: content.path, with: content)
85
+ },
86
+ audioBrowser.playerOptionsChangedEmitter.subscribeOnMain { [weak self] in self?.playerOptionsChanged() },
87
+ audioBrowser.externalContentChangedEmitter.subscribeOnMain { [weak self] path in
88
+ guard let self else { return }
89
+ if path == HybridAudioBrowser.invalidateAllSentinel {
90
+ spawn { await $0.refreshAllDisplayedTemplates() }
91
+ } else {
92
+ spawn { await $0.refreshContent(for: path) }
93
+ }
94
+ },
95
+ audioBrowser.browseGate.changed.subscribeOnMain { [weak self] _ in self?.gateChanged() },
96
+ audioBrowser.activeTrackChangedEmitter.subscribeOnMain { [weak self] _ in self?.updatePlayingIndicators() },
97
+ ]
98
+ nowPlaying.start()
99
+ spawn { await $0.buildInitialInterface() }
100
+ }
101
+
102
+ func end() {
103
+ ended = true
104
+ for task in tasks.values {
105
+ task.cancel()
106
+ }
107
+ tasks = [:]
108
+ subscriptions = []
109
+ nowPlaying.end()
110
+ }
111
+
112
+ /// Runs `work` as a task this session owns, cancelled by `end()`.
113
+ func spawn(_ work: @escaping @MainActor (CarPlaySession) async -> Void) {
114
+ // A handler queued before `end()` can still call in; the next run owns the screen.
115
+ guard !ended else { return }
116
+ let id = UUID()
117
+ tasks[id] = Task { [weak self] in
118
+ guard let self else { return }
119
+ await work(self)
120
+ tasks[id] = nil
121
+ }
122
+ }
123
+
124
+ // MARK: - Root Builds
125
+
126
+ /// Starts a root build; pass the result to `isCurrent(_:)` after every suspension.
127
+ func beginRootBuild() -> UInt {
128
+ builtTabs = nil
129
+ rootBuildGeneration &+= 1
130
+ return rootBuildGeneration
131
+ }
132
+
133
+ func isCurrent(_ build: UInt) -> Bool {
134
+ build == rootBuildGeneration && !Task.isCancelled
135
+ }
136
+
137
+ // MARK: - Template Events
138
+
139
+ func templateDidAppear(_ template: CPTemplate) {
140
+ // Popping back from Now Playing reveals the tab bar, not a list; both repaint the playing
141
+ // indicator, which may have changed while the list was covered.
142
+ guard template is CPListTemplate || template is CPTabBarTemplate else { return }
143
+ if let path = template.carPlayInfo?.path { navigatingPaths.remove(path) }
144
+ if rebuildTabsIfAtRoot() { return }
145
+ updatePlayingIndicators()
146
+ if let list = template as? CPListTemplate { loadContentIfNeeded(for: list) }
147
+ }
148
+
149
+ func templateDidDisappear(_ template: CPTemplate) {
150
+ // Whether popping Now Playing reports the list appearing or only Now Playing leaving is a
151
+ // platform detail; both repaint the indicator.
152
+ guard template is CPNowPlayingTemplate else { return }
153
+ updatePlayingIndicators()
154
+ }
155
+
156
+ // MARK: - Playing Indicators
157
+
158
+ var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
159
+ switch audioBrowser.carPlayPlayingIndicatorLocation {
160
+ case .trailing: .trailing
161
+ case .leading: .leading
162
+ }
163
+ }
164
+
165
+ private func playerOptionsChanged() {
166
+ // The factory's copy is the last-applied location: sweep only on a real change.
167
+ let location = playingIndicatorLocation
168
+ guard listItemFactory.playingIndicatorLocation != location else { return }
169
+ listItemFactory.playingIndicatorLocation = location
170
+ updatePlayingIndicators()
171
+ }
172
+
173
+ /// Updates every displayed list item's playing flag and indicator location.
174
+ func updatePlayingIndicators() {
175
+ let location = playingIndicatorLocation
176
+ for template in displayedListTemplates() {
177
+ var changed = false
178
+ for section in template.sections {
179
+ for case let item as CPListItem in section.items {
180
+ guard let identity = item.carPlayItemInfo?.identity else { continue }
181
+ let isPlaying = player?.currentTrack?.identity == identity
182
+ if item.isPlaying != isPlaying {
183
+ item.isPlaying = isPlaying
184
+ changed = true
185
+ }
186
+ if item.playingIndicatorLocation != location {
187
+ item.playingIndicatorLocation = location
188
+ changed = true
189
+ }
190
+ }
191
+ }
192
+ // A flag set on a displayed item does not reliably reach the car screen; re-pushing the
193
+ // sections re-serializes every item, so it is done only when something changed.
194
+ if changed { template.updateSections(template.sections) }
195
+ }
196
+ }
197
+
198
+ /// The list templates on screen or one tap away: the tab roots, then the pushed stack.
199
+ func displayedListTemplates() -> [CPListTemplate] {
200
+ var templates: [CPListTemplate] = []
201
+ let tabRoots = (interfaceController.rootTemplate as? CPTabBarTemplate)?.templates ?? []
202
+ for case let list as CPListTemplate in tabRoots + interfaceController.templates
203
+ where !templates.contains(where: { $0 === list })
204
+ {
205
+ templates.append(list)
206
+ }
207
+ return templates
208
+ }
209
+ }