react-native-audio-browser 0.2.0-next.12 → 0.2.0-next.14

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 (185) hide show
  1. package/README.md +10 -0
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +8 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +52 -0
  5. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  6. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -11
  7. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +1 -1
  8. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  9. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  10. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  11. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +21 -22
  12. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  13. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  14. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  15. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  16. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +16 -6
  17. package/android/src/main/res/values/strings.xml +37 -2
  18. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +50 -5
  19. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +9 -0
  20. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  21. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  22. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  23. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  24. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  25. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  26. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  27. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +10 -9
  28. package/ios/Browser/BrowserManager.swift +106 -45
  29. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  30. package/ios/Browser/JsonModels.swift +81 -15
  31. package/ios/Browser/SectionPresentation.swift +30 -0
  32. package/ios/Browser/StyleResolver.swift +11 -0
  33. package/ios/Browser/TabBarEntries.swift +7 -1
  34. package/ios/CarPlay/CarPlayController.swift +139 -35
  35. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  36. package/ios/Extension/Section+Copying.swift +1 -3
  37. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  38. package/ios/Http/HttpClient.swift +8 -2
  39. package/ios/HybridAudioBrowser.swift +56 -12
  40. package/ios/Model/NitroTypeStubs.swift +60 -1
  41. package/ios/Player/MediaIntentCriteria.swift +13 -5
  42. package/ios/Player/MediaLoader.swift +33 -9
  43. package/ios/Player/NowPlayingUpdater.swift +53 -5
  44. package/ios/Player/PlaybackCoordinator.swift +11 -4
  45. package/ios/Player/PlaybackTimer.swift +8 -2
  46. package/ios/Player/QueueManager.swift +36 -10
  47. package/ios/Player/ShuffleOrder.swift +23 -0
  48. package/ios/Player/SleepTimerHandling.swift +13 -0
  49. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  50. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  51. package/ios/Tests/JsonModelsTests.swift +76 -1
  52. package/ios/Tests/QueueManagerTests.swift +72 -6
  53. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  54. package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -2
  55. package/ios/Tests/SectionPresentationTests.swift +29 -0
  56. package/ios/Tests/StyleResolverTests.swift +60 -0
  57. package/ios/Tests/Support/TestTypes.swift +7 -0
  58. package/ios/TrackPlayer.swift +57 -34
  59. package/ios/Util/NetworkMonitor.swift +43 -26
  60. package/ios/Util/SFSymbolRenderer.swift +24 -2
  61. package/ios/Util/SVGProcessor.swift +9 -2
  62. package/lib/commonjs/features/browser-config.js +1 -0
  63. package/lib/commonjs/features/browser-config.js.map +1 -1
  64. package/lib/commonjs/features/playback/progress.js +2 -1
  65. package/lib/commonjs/features/playback/progress.js.map +1 -1
  66. package/lib/commonjs/features/playback/rate.js +8 -1
  67. package/lib/commonjs/features/playback/rate.js.map +1 -1
  68. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  69. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  70. package/lib/module/features/browser-config.js +1 -0
  71. package/lib/module/features/browser-config.js.map +1 -1
  72. package/lib/module/features/playback/progress.js +2 -1
  73. package/lib/module/features/playback/progress.js.map +1 -1
  74. package/lib/module/features/playback/rate.js +8 -1
  75. package/lib/module/features/playback/rate.js.map +1 -1
  76. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  77. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  78. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  79. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  80. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  81. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  82. package/lib/typescript/src/types/browser-native.d.ts +2 -1
  83. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  84. package/lib/typescript/src/types/browser-nodes.d.ts +116 -9
  85. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  86. package/lib/typescript/src/types/browser.d.ts +36 -0
  87. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  88. package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
  89. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  90. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  91. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  94. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  95. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  98. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  99. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  100. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
  101. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +6 -0
  102. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  103. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  104. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  105. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  106. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  107. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +13 -0
  108. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  109. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  110. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +20 -1
  111. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  112. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  113. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  114. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  115. package/nitrogen/generated/android/c++/JSection.hpp +7 -5
  116. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  117. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  118. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  119. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  120. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  121. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  122. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
  123. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  124. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  125. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +7 -4
  126. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +2 -5
  127. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  128. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  129. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
  130. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -0
  131. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  132. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  133. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  134. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
  135. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  136. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  137. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +48 -1
  138. package/nitrogen/generated/ios/swift/Section.swift +1 -19
  139. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  140. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  141. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  142. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  143. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  144. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -2
  145. package/nitrogen/generated/shared/c++/Section.hpp +1 -5
  146. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  147. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  148. package/package.json +7 -2
  149. package/src/features/browser-config.ts +1 -0
  150. package/src/features/playback/progress.ts +2 -1
  151. package/src/features/playback/rate.ts +8 -1
  152. package/src/style-resolution.test.ts +72 -1
  153. package/src/types/browser-native.ts +5 -1
  154. package/src/types/browser-nodes.ts +124 -9
  155. package/src/types/browser.ts +38 -0
  156. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  157. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  158. package/website/guide/android-auto.md +114 -0
  159. package/website/guide/android-certificates.md +409 -0
  160. package/website/guide/artwork.md +255 -0
  161. package/website/guide/audio-output.md +135 -0
  162. package/website/guide/automotive.md +86 -0
  163. package/website/guide/basic-usage.md +151 -0
  164. package/website/guide/battery.md +115 -0
  165. package/website/guide/browser.md +581 -0
  166. package/website/guide/carplay.md +620 -0
  167. package/website/guide/configuration.md +173 -0
  168. package/website/guide/equalizer.md +231 -0
  169. package/website/guide/errors.md +310 -0
  170. package/website/guide/favorites.md +154 -0
  171. package/website/guide/gate.md +143 -0
  172. package/website/guide/getting-started.md +46 -0
  173. package/website/guide/hooks.md +238 -0
  174. package/website/guide/live-streams.md +125 -0
  175. package/website/guide/metadata.md +239 -0
  176. package/website/guide/migrating-from-track-player.md +244 -0
  177. package/website/guide/native-callback-fetch.md +127 -0
  178. package/website/guide/network.md +86 -0
  179. package/website/guide/now-playing.md +175 -0
  180. package/website/guide/playback.md +328 -0
  181. package/website/guide/queue.md +300 -0
  182. package/website/guide/remote-controls.md +256 -0
  183. package/website/guide/search.md +255 -0
  184. package/website/guide/sleep-timer.md +268 -0
  185. package/website/guide/track.md +246 -0
@@ -0,0 +1,620 @@
1
+ ---
2
+ description: 'CarPlay for audio apps: browse templates generated from your browse tree, the UIScene and headless React Native host setup, and Siri voice search via INPlayMediaIntent.'
3
+ ---
4
+
5
+ # CarPlay
6
+
7
+ ## Overview
8
+
9
+ CarPlay integration for audio apps works through two systems:
10
+
11
+ 1. **Browse templates** — Tab-based navigation generated from the **browse tree** you declare with `configureBrowser`, handled automatically by the library. If you haven't set that up yet, see [Basic Usage](/guide/basic-usage) for tabs, routes, and sources — this page assumes it's already configured. Every config key used below is documented in [`BrowserConfiguration`](/api/types/browser/#browserconfiguration).
12
+ 2. **Siri voice search** — The search button on CarPlay launches Siri, which creates an `INPlayMediaIntent`. The library resolves it through the same [`search`](/guide/search) source that powers your in-app search, then queues and plays the results. This requires additional setup in your app (see [Siri Voice Search](#siri-voice-search) below).
13
+
14
+ On CarPlay's audio interface the search button always triggers Siri — audio apps don't use `CPSearchTemplate` (that's a navigation-app API).
15
+
16
+ The two systems are independent, but **both** require the [React Native host setup](#react-native-host-uiscene-headless) (UIScene lifecycle + headless RN boot) — that's the one prerequisite every CarPlay app needs, even browse-only. Siri then adds its own entitlement, Info.plist keys, and intent handler on top. The **Loading States** and **Album Line Navigation** sections are optional browse polish — skip them to get a minimal integration working.
17
+
18
+ ::: info Before you start
19
+ This page assumes you've installed the library and have the player running: add `react-native-audio-browser`, run `pod install` in `ios/`, and call `setupPlayer()` + `configureBrowser()` at startup. See [Getting Started](/guide/getting-started) and [Basic Usage](/guide/basic-usage) first.
20
+ :::
21
+
22
+ ## Setup
23
+
24
+ ### Entitlement
25
+
26
+ Your app needs the CarPlay audio entitlement in your `.entitlements` file:
27
+
28
+ ```xml
29
+ <key>com.apple.developer.carplay-audio</key>
30
+ <true/>
31
+ ```
32
+
33
+ If your target has no `.entitlements` file yet, add one via Xcode (**Signing & Capabilities → + Capability**) and make sure the target's **`CODE_SIGN_ENTITLEMENTS`** build setting points at it — otherwise the key is ignored at build time.
34
+
35
+ The CarPlay audio entitlement also must be **granted by Apple** via the [CarPlay entitlement request](https://developer.apple.com/contact/carplay/) before it works on a device — approval can take a while, so request it early. You can still develop against the [CarPlay Simulator](#testing) meanwhile.
36
+
37
+ ### Scene manifest (Info.plist)
38
+
39
+ Your scene manifest needs **two** scene configurations: the CarPlay template scene (driven by the library's `RNABCarPlaySceneDelegate`) and your phone window scene (driven by your own `PhoneSceneDelegate`, shown in the host setup below). If your app isn't scene-based yet, this whole `UIApplicationSceneManifest` is new:
40
+
41
+ ```xml
42
+ <key>UIApplicationSceneManifest</key>
43
+ <dict>
44
+ <key>UIApplicationSupportsMultipleScenes</key>
45
+ <true/>
46
+ <key>UISceneConfigurations</key>
47
+ <dict>
48
+ <key>CPTemplateApplicationSceneSessionRoleApplication</key>
49
+ <array>
50
+ <dict>
51
+ <key>UISceneConfigurationName</key>
52
+ <string>CarPlay</string>
53
+ <key>UISceneDelegateClassName</key>
54
+ <string>RNABCarPlaySceneDelegate</string>
55
+ </dict>
56
+ </array>
57
+ <key>UIWindowSceneSessionRoleApplication</key>
58
+ <array>
59
+ <dict>
60
+ <key>UISceneConfigurationName</key>
61
+ <string>Phone</string>
62
+ <!-- $(PRODUCT_MODULE_NAME) = your app's Swift module -->
63
+ <key>UISceneDelegateClassName</key>
64
+ <string>$(PRODUCT_MODULE_NAME).PhoneSceneDelegate</string>
65
+ </dict>
66
+ </array>
67
+ </dict>
68
+ </dict>
69
+ ```
70
+
71
+ The two `UISceneDelegateClassName` strings name the delegates for each scene: the library's `RNABCarPlaySceneDelegate` (ships with the library — you never implement it; it links in once the `AudioBrowser` pod is installed) and your own `PhoneSceneDelegate`. These manifest strings are only a fallback — once you add `configurationForConnecting` (next section), your code picks each scene's delegate. So `PhoneSceneDelegate` not resolving yet, and the CarPlay value being a bare class name, are both expected until you finish the host setup next.
72
+
73
+ ## React Native host (UIScene + headless)
74
+
75
+ This section is **required for any CarPlay integration** — browse-only or Siri. CarPlay connects through scenes and can launch your app in the background with no phone window, so React Native has to be startable both on the foreground path (your phone scene) and headless (a hidden window). Set this up once; both the browse templates and Siri depend on it.
76
+
77
+ ::: warning Two prerequisites
78
+ **UIScene lifecycle** — CarPlay requires a `UIWindowSceneDelegate` for the phone window. The default `npx react-native init` app isn't scene-based; if yours still sets up its window directly in `AppDelegate`, migrate to scenes first (the sections below build all the pieces).
79
+
80
+ **New Architecture host, Swift** — these snippets assume `RCTReactNativeFactory` (present on every React Native version the library supports) and a Swift `AppDelegate`. If yours is still Objective-C (`.mm`), migrating it to Swift first is simpler than translating this wiring by hand — scaffold a throwaway app (`npx @react-native-community/cli init Tmp`) and copy its `AppDelegate.swift` as a reference. ObjC works too (the bridging header is then unnecessary, see [Siri step 3](#_3-bridging-header-swift-appdelegate-only)) but isn't shown here.
81
+ :::
82
+
83
+ ### The factory
84
+
85
+ These AppDelegate snippets need the New-Architecture React host imports:
86
+
87
+ ```swift
88
+ import UIKit
89
+ import Intents
90
+ import React
91
+ import React_RCTAppDelegate
92
+ import ReactAppDependencyProvider
93
+ ```
94
+
95
+ Create the `RCTReactNativeFactory` in `didFinishLaunchingWithOptions`, but **don't** start RN there — the phone scene starts it on the foreground path, and the headless helper starts it on the background path:
96
+
97
+ ```swift
98
+ var reactNativeFactory: RCTReactNativeFactory?
99
+ private var reactNativeDelegate: ReactNativeDelegate?
100
+
101
+ func application(
102
+ _ application: UIApplication,
103
+ didFinishLaunchingWithOptions launchOptions:
104
+ [UIApplication.LaunchOptionsKey: Any]? = nil
105
+ ) -> Bool {
106
+ let delegate = ReactNativeDelegate()
107
+ delegate.dependencyProvider = RCTAppDependencyProvider()
108
+ reactNativeDelegate = delegate
109
+ reactNativeFactory = RCTReactNativeFactory(delegate: delegate)
110
+ // Note: do NOT start RN here — the scene delegates own startup.
111
+ return true
112
+ }
113
+ ```
114
+
115
+ `ReactNativeDelegate` is a `RCTDefaultReactNativeFactoryDelegate` subclass **you** define (standard New-Architecture AppDelegate boilerplate — it supplies your JS bundle URL); `RCTAppDependencyProvider` is a React-core class. Neither comes from this library. The default New-Arch template generates both — if you scaffolded with it you already have them; otherwise this is the whole subclass:
116
+
117
+ ```swift
118
+ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
119
+ override func sourceURL(for bridge: RCTBridge) -> URL? { bundleURL() }
120
+
121
+ override func bundleURL() -> URL? {
122
+ #if DEBUG
123
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
124
+ #else
125
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
126
+ #endif
127
+ }
128
+ }
129
+ ```
130
+
131
+ ### The headless helper
132
+
133
+ Two things call this helper, which is why it's required even for **browse-only** apps:
134
+
135
+ - The library's `RNABCarPlaySceneDelegate` calls `startReactNativeHeadless` on your `AppDelegate` (via an optional protocol) when CarPlay connects with no phone scene running — so a browse-only CarPlay launch boots RN automatically.
136
+ - Your Siri intent handler calls it (step 4) for a background intent launch.
137
+
138
+ ::: warning Name it exactly `startReactNativeHeadless`
139
+ The library finds this method **by selector** — it must be named exactly `startReactNativeHeadless` (no args) on your `AppDelegate`, or the library's CarPlay delegate won't be able to boot RN and browse screens will hang empty.
140
+ :::
141
+
142
+ ```swift
143
+ // Properties on your AppDelegate.
144
+ private var reactNativeStarted = false
145
+ private var headlessWindow: UIWindow?
146
+
147
+ /// Boots React Native into a hidden window when no UI scene exists yet.
148
+ /// Called by the library's CarPlay delegate (browse) and your Siri handler.
149
+ /// Safe to call repeatedly — it starts RN at most once.
150
+ func startReactNativeHeadless() {
151
+ guard !reactNativeStarted else { return }
152
+ reactNativeStarted = true
153
+
154
+ // RCTReactNativeFactory needs a window to host the root view.
155
+ headlessWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
156
+ headlessWindow?.isHidden = true
157
+
158
+ // `startReactNative(withModuleName:in:launchOptions:)` is a React-core method
159
+ // on RCTReactNativeFactory — not a library API. "YourAppModuleName" is your
160
+ // app.json `name` (= the AppRegistry.registerComponent string); the phone
161
+ // scene and this headless path must use that same name.
162
+ reactNativeFactory?.startReactNative(
163
+ // ⚠️ replace with your app.json `name`
164
+ withModuleName: "YourAppModuleName",
165
+ in: headlessWindow,
166
+ launchOptions: nil
167
+ )
168
+ }
169
+
170
+ /// Called by the phone scene after it starts RN on the foreground path, so the
171
+ /// headless helper never double-starts it. Both paths gate on the same
172
+ /// `reactNativeStarted` flag, so RN starts exactly once.
173
+ func markReactNativeStarted() {
174
+ reactNativeStarted = true
175
+ }
176
+ ```
177
+
178
+ ### The phone scene delegate
179
+
180
+ Create this as a **new `.swift` file in your app target** (e.g. `PhoneSceneDelegate.swift`, next to `AppDelegate.swift`). It must compile into your app's own module — that's why the Info.plist refers to it as `$(PRODUCT_MODULE_NAME).PhoneSceneDelegate`. It starts RN on the foreground path and flips the shared flag via `markReactNativeStarted()`:
181
+
182
+ ```swift
183
+ class PhoneSceneDelegate: UIResponder, UIWindowSceneDelegate {
184
+ var window: UIWindow?
185
+
186
+ func scene(
187
+ _ scene: UIScene,
188
+ willConnectTo session: UISceneSession,
189
+ options connectionOptions: UIScene.ConnectionOptions
190
+ ) {
191
+ guard session.role == .windowApplication,
192
+ let appDelegate = UIApplication.shared.delegate as? AppDelegate,
193
+ let windowScene = scene as? UIWindowScene else { return }
194
+
195
+ let window = UIWindow(windowScene: windowScene)
196
+ appDelegate.window = window
197
+ appDelegate.markReactNativeStarted() // foreground path owns RN startup
198
+
199
+ // Module name must exactly match the headless helper / registerComponent.
200
+ appDelegate.reactNativeFactory?.startReactNative(
201
+ // ⚠️ replace with your app.json `name`
202
+ withModuleName: "YourAppModuleName",
203
+ in: window,
204
+ launchOptions: nil
205
+ )
206
+ self.window = window
207
+ }
208
+ }
209
+ ```
210
+
211
+ See the example app's [`AppDelegate.swift`](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/ios/AudioBrowserExample/AppDelegate.swift) and [`PhoneSceneDelegate.swift`](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/ios/AudioBrowserExample/PhoneSceneDelegate.swift) for the complete, copy-pasteable versions.
212
+
213
+ ### Routing scene connections
214
+
215
+ Now that both delegates exist, route each incoming scene to the right one from your `AppDelegate` — the library's `RNABCarPlaySceneDelegate` for CarPlay, your `PhoneSceneDelegate` for the phone window:
216
+
217
+ ```swift
218
+ func application(
219
+ _ application: UIApplication,
220
+ configurationForConnecting connectingSceneSession: UISceneSession,
221
+ options: UIScene.ConnectionOptions
222
+ ) -> UISceneConfiguration {
223
+ if connectingSceneSession.role == UISceneSession.Role.carTemplateApplication {
224
+ let config = UISceneConfiguration(
225
+ name: "CarPlay", sessionRole: connectingSceneSession.role)
226
+ // RNABCarPlaySceneDelegate is an ObjC class the library ships, so it's
227
+ // resolved by name rather than imported.
228
+ config.delegateClass =
229
+ NSClassFromString("RNABCarPlaySceneDelegate") as? UIResponder.Type
230
+ return config
231
+ }
232
+ // Phone scene — your own delegate, defined above.
233
+ let config = UISceneConfiguration(
234
+ name: "Phone", sessionRole: connectingSceneSession.role)
235
+ config.delegateClass = PhoneSceneDelegate.self
236
+ return config
237
+ }
238
+ ```
239
+
240
+ As noted in the scene manifest above, the `delegateClass` you set here is what actually routes each scene; the Info.plist names are just the fallback. Seeing each delegate in both places is expected.
241
+
242
+ One coupling that **is** load-bearing: the `name:` you pass to `UISceneConfiguration` (`"CarPlay"` / `"Phone"`) must match a `UISceneConfigurationName` in the manifest. Rename one without the other and iOS crashes at connect time with _"no UISceneConfiguration named …"_.
243
+
244
+ ### The assembled `AppDelegate`
245
+
246
+ The fragments above all live on one `AppDelegate`. Here is the whole class in one piece — the authoritative copy-paste. It **replaces** the `@main class AppDelegate` your RN template generated in `ios/<app>/AppDelegate.swift` (you keep one `@main`; don't add a second). The Siri `handlerFor` method at the end is needed only if you add [Siri Voice Search](#siri-voice-search):
247
+
248
+ ```swift
249
+ import UIKit
250
+ import Intents
251
+ import React
252
+ import React_RCTAppDelegate
253
+ import ReactAppDependencyProvider
254
+
255
+ @main
256
+ class AppDelegate: UIResponder, UIApplicationDelegate {
257
+ var window: UIWindow?
258
+
259
+ var reactNativeFactory: RCTReactNativeFactory?
260
+ private var reactNativeDelegate: ReactNativeDelegate?
261
+ private var reactNativeStarted = false
262
+ private var headlessWindow: UIWindow?
263
+
264
+ func application(
265
+ _ application: UIApplication,
266
+ didFinishLaunchingWithOptions launchOptions:
267
+ [UIApplication.LaunchOptionsKey: Any]? = nil
268
+ ) -> Bool {
269
+ let delegate = ReactNativeDelegate()
270
+ delegate.dependencyProvider = RCTAppDependencyProvider()
271
+ reactNativeDelegate = delegate
272
+ reactNativeFactory = RCTReactNativeFactory(delegate: delegate)
273
+ return true // don't start RN here — the scene delegates own startup
274
+ }
275
+
276
+ // Route each connecting scene to its delegate.
277
+ func application(
278
+ _ application: UIApplication,
279
+ configurationForConnecting connectingSceneSession: UISceneSession,
280
+ options: UIScene.ConnectionOptions
281
+ ) -> UISceneConfiguration {
282
+ if connectingSceneSession.role
283
+ == UISceneSession.Role.carTemplateApplication {
284
+ let config = UISceneConfiguration(
285
+ name: "CarPlay", sessionRole: connectingSceneSession.role)
286
+ config.delegateClass =
287
+ NSClassFromString("RNABCarPlaySceneDelegate") as? UIResponder.Type
288
+ return config
289
+ }
290
+ let config = UISceneConfiguration(
291
+ name: "Phone", sessionRole: connectingSceneSession.role)
292
+ config.delegateClass = PhoneSceneDelegate.self
293
+ return config
294
+ }
295
+
296
+ // Boots RN into a hidden window when there's no UI scene (background launch).
297
+ func startReactNativeHeadless() {
298
+ guard !reactNativeStarted else { return }
299
+ reactNativeStarted = true
300
+ headlessWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
301
+ headlessWindow?.isHidden = true
302
+ reactNativeFactory?.startReactNative(
303
+ // ⚠️ replace with your app.json `name`
304
+ withModuleName: "YourAppModuleName",
305
+ in: headlessWindow,
306
+ launchOptions: nil
307
+ )
308
+ }
309
+
310
+ // Called by PhoneSceneDelegate so the headless path won't double-start RN.
311
+ func markReactNativeStarted() { reactNativeStarted = true }
312
+
313
+ // Siri only — needed when you add Siri Voice Search (step 4). Requires the
314
+ // bridging header so `RNABAudioBrowser` is visible in Swift.
315
+ func application(
316
+ _ application: UIApplication,
317
+ handlerFor intent: INIntent
318
+ ) -> Any? {
319
+ startReactNativeHeadless()
320
+ return RNABAudioBrowser.handler(for: intent)
321
+ }
322
+ }
323
+
324
+ // Same file as AppDelegate. Standard New-Arch host delegate —
325
+ // supplies the bundle URL.
326
+ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
327
+ override func sourceURL(for bridge: RCTBridge) -> URL? { bundleURL() }
328
+
329
+ override func bundleURL() -> URL? {
330
+ #if DEBUG
331
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
332
+ #else
333
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
334
+ #endif
335
+ }
336
+ }
337
+ ```
338
+
339
+ This is the complete `AppDelegate.swift` (the `@main class AppDelegate` plus its `ReactNativeDelegate` in the same file). `PhoneSceneDelegate` lives in its **own** `PhoneSceneDelegate.swift` (shown above). Those two files are the complete native side.
340
+
341
+ ::: warning Don't blindly overwrite an AppDelegate you've customized
342
+ This block is the minimal shape. If your existing `AppDelegate` / `ReactNativeDelegate` already has extra overrides — bundle customization, linking setup, push/deep-link handlers, `customize(rootView:)`, extra modules — **keep them**. Merge the CarPlay additions (the factory split, `configurationForConnecting`, the headless helper, `markReactNativeStarted`, and the Siri `handlerFor`) into your file rather than replacing it wholesale.
343
+ :::
344
+
345
+ ### Where to call `configureBrowser`
346
+
347
+ A headless launch boots RN into a hidden window with **no components mounted**, so a `configureBrowser` call inside a component's `useEffect` will never run on a CarPlay/Siri cold launch. Call it (and `setupPlayer`) as a **top-level side-effect in `index.js`** — alongside `AppRegistry.registerComponent` — so it executes when the JS bundle loads, mounted UI or not.
348
+
349
+ First, because `configureBrowser` **replaces** the whole config, build one object with everything — tabs, routes, and (for Siri) search — together:
350
+
351
+ ```ts
352
+ const browserConfig = {
353
+ // Tabs along the top of the CarPlay browser (max 4 — capped for
354
+ // Android Auto + CarPlay compatibility; extra tabs dropped with a warning).
355
+ tabs: [{ title: 'Home', path: '/' }],
356
+
357
+ // routes: path → BrowserSource (static page, callback, or HTTP).
358
+ routes: {
359
+ '/': async () => ({
360
+ title: 'Home',
361
+ children: [{ title: 'Crunchy Greens FM', src: 'https://...' }]
362
+ })
363
+ },
364
+
365
+ // search: required for Siri voice search (omit for browse-only).
366
+ search: { baseUrl: 'https://api.example.com/search' }
367
+ }
368
+ ```
369
+
370
+ Then apply it as a top-level side-effect in `index.js`:
371
+
372
+ ```js
373
+ // index.js
374
+ import { AppRegistry } from 'react-native'
375
+ import { configureBrowser, setupPlayer } from 'react-native-audio-browser'
376
+ import App from './App'
377
+ import { name as appName } from './app.json'
378
+ import { browserConfig } from './browserConfig' // the object above
379
+
380
+ AppRegistry.registerComponent(appName, () => App)
381
+
382
+ // Runs on every launch — including headless CarPlay / Siri — because it's a
383
+ // module side-effect, not tied to a mounted component.
384
+ void (async () => {
385
+ await setupPlayer()
386
+ configureBrowser(browserConfig)
387
+ })()
388
+ ```
389
+
390
+ The `"YourAppModuleName"` in the Swift snippets above is exactly this `appName` — your `app.json` `name`, identical to the string passed to `AppRegistry.registerComponent` (the same in the default RN template). The headless helper and the phone scene must both start RN with that name.
391
+
392
+ ## Siri Voice Search
393
+
394
+ Siri voice search builds on the [React Native host setup](#react-native-host-uiscene-headless) above — make sure that's in place first. Then complete the steps below, in order:
395
+
396
+ - **Step 0 — configure a `search` source** (the warning box just below; required, easy to forget)
397
+ - **Steps 1–4** — Siri entitlement, Info.plist keys, bridging header, intent handler
398
+
399
+ ::: warning Step 0 (required) — a configured `search` source
400
+ Siri voice search resolves spoken queries through the **same [`search`](/guide/search) source as your in-app search**. If you haven't set one, the mic will open, hear "play jazz", and return **nothing** — steps 1–4 will look wired up but produce silence. Configure it once:
401
+
402
+ ```ts
403
+ configureBrowser({
404
+ // …your tabs / routes …
405
+ search: {
406
+ // GET https://api.example.com/search?q=jazz — the library appends
407
+ // `q` plus structured voice params (`mode`, `genre`, `artist`,
408
+ // `album`, `reference`) and expects a page object back:
409
+ // { title?, children: Track[] }, where each playable child Track
410
+ // needs at least `title` + `src`.
411
+ baseUrl: 'https://api.example.com/search'
412
+ }
413
+ })
414
+ ```
415
+
416
+ `configureBrowser` **replaces** the entire configuration each time it's called — it doesn't merge. So this isn't a second call: fold `search` into the single `configureBrowser({ tabs, routes, search, … })` you make at startup (see [Where to call `configureBrowser`](#where-to-call-configurebrowser)). The snippets on this page show one key at a time for brevity.
417
+
418
+ Naming note: in the **callback** form the spoken text arrives as `query` (`search: async ({ query }) => …`); on the **HTTP** form the library sends it as the wire param **`q`** (`?q=jazz`). Same value, two names depending on which form you use.
419
+
420
+ See [Search](/guide/search) for the full `SearchParams` (genre / artist / album / `mode` / `reference`), the callback form (return [`Track`](/api/types/browser-nodes/#track)`[]` yourself), and `transform` for rewriting requests. This is required for Siri; the browse templates work without it.
421
+ :::
422
+
423
+ CarPlay's tab bar already has a **search button that launches Siri automatically** — you get that for free once the steps below are wired up. Separately, the library can also show an **"Ask Siri to Play Audio" cell inside a list** as an extra entry point. This `carPlaySiriListButton` is optional polish — it's not what enables Siri search. To add it, set [`carPlaySiriListButton`](/api/types/browser/#carplaysirilistbuttonposition) on the resolved content returned by your route source:
424
+
425
+ ```ts
426
+ configureBrowser({
427
+ // `routes` maps a path → a `BrowserSource`: a static page object, an async
428
+ // callback returning one (as here), or an HTTP request config. See
429
+ // Basic Usage and the BrowserSource type: /api/types/browser/#browsersource
430
+ routes: {
431
+ '/': async () => ({
432
+ title: 'Home',
433
+ carPlaySiriListButton: 'top',
434
+ children: [{ title: 'Crunchy Greens FM', src: 'https://...' }]
435
+ })
436
+ }
437
+ })
438
+ ```
439
+
440
+ (Tabs themselves come from your separate [`tabs`](/api/types/browser/#tabs) config, not from this route — the `'/'` route here is just where the button is attached. See [Basic Usage](/guide/basic-usage) for how `tabs` and `routes` fit together.)
441
+
442
+ The `carPlaySiriListButton` property accepts `'top'` or `'bottom'` to control where the system assistant cell appears. It only affects CarPlay — it has no effect on the phone UI.
443
+
444
+ ::: tip
445
+ The button just launches Siri — it does **nothing** until the entitlement, Info.plist keys, and intent handler in the steps below are in place. Wire those up first, or tapping it will appear to do nothing.
446
+ :::
447
+
448
+ To complete the setup, your app needs the Siri entitlement, a couple of Info.plist keys, a bridging header, and a one-line handler in your AppDelegate. Media intents are handled in-app: Siri delivers the `INPlayMediaIntent` straight to your `AppDelegate`.
449
+
450
+ ::: info iOS version
451
+ The library's minimum is **iOS 16**. The other version numbers on this page are finer-grained capability notes within that floor: the in-app handler API (`application(_:handlerFor:)`) exists from iOS 14 so you never need to gate on it, and the automatic CarPlay loading spinner is iOS 18.4+.
452
+ :::
453
+
454
+ ### 1. Siri entitlement
455
+
456
+ Add the Siri entitlement to the **same `.entitlements` file** as the CarPlay key — both live in one `<dict>`:
457
+
458
+ ```xml
459
+ <!-- YourApp.entitlements -->
460
+ <plist version="1.0">
461
+ <dict>
462
+ <key>com.apple.developer.carplay-audio</key>
463
+ <true/>
464
+ <key>com.apple.developer.siri</key>
465
+ <true/>
466
+ </dict>
467
+ </plist>
468
+ ```
469
+
470
+ The reliable way to add this is Xcode → **Signing & Capabilities → + Capability → Siri**, which writes the key and enables **SiriKit on your App ID** (via automatic signing or the developer portal). Unlike the CarPlay audio entitlement, Siri does **not** require a manual Apple approval request — enabling the capability is enough.
471
+
472
+ ### 2. Siri Info.plist keys
473
+
474
+ Add Siri usage description and supported media categories to your app's Info.plist:
475
+
476
+ ```xml
477
+ <key>NSSiriUsageDescription</key>
478
+ <string>Siri is used for voice-controlled media playback via CarPlay.</string>
479
+ <key>INIntentsSupported</key>
480
+ <array>
481
+ <string>INPlayMediaIntent</string>
482
+ <!-- Optional — only for "like/dislike" / "add to favorites": -->
483
+ <string>INUpdateMediaAffinityIntent</string>
484
+ <string>INAddMediaIntent</string>
485
+ </array>
486
+ <key>INSupportedMediaCategories</key>
487
+ <array>
488
+ <!-- PLACEHOLDER — replace with the categories matching YOUR content.
489
+ E.g. INMediaCategoryRadio for live radio, INMediaCategoryPodcasts
490
+ for podcasts, INMediaCategoryMusic for music. List every one you
491
+ support. -->
492
+ <string>INMediaCategoryRadio</string>
493
+ </array>
494
+ ```
495
+
496
+ `INIntentsSupported` registers the intents your app can handle. The library's handler vends a handler for three intents:
497
+
498
+ - **`INPlayMediaIntent`** — search / resume / play (the core voice-search flow).
499
+ - **`INUpdateMediaAffinityIntent`** — "like" / "dislike" the current track.
500
+ - **`INAddMediaIntent`** — add the current track to favorites.
501
+
502
+ Register only the ones you want; `INPlayMediaIntent` alone is enough for voice search. The [`INSupportedMediaCategories`](https://developer.apple.com/documentation/sirikit/inmediacategory) values tell Siri which spoken requests to route to your app — if they don't cover your content, Siri may hand the request to a **different app**, so list every category you actually support.
503
+
504
+ ### 3. Bridging Header (Swift AppDelegate only)
505
+
506
+ The intent API lives in the library's CocoaPod, **`AudioBrowser`** (the umbrella header for the `react-native-audio-browser` package). An **Objective-C** AppDelegate just `#import <AudioBrowser/RNABAudioBrowser.h>` directly and skips this step.
507
+
508
+ A **Swift** AppDelegate reaches it through a bridging header. If your app already has one (check `SWIFT_OBJC_BRIDGING_HEADER`), add the import to it; otherwise create one and point that build setting at it:
509
+
510
+ **YourApp-Bridging-Header.h:**
511
+
512
+ ```objc
513
+ #import <AudioBrowser/RNABAudioBrowser.h>
514
+ ```
515
+
516
+ ::: tip If the import won't resolve
517
+ `<AudioBrowser/...>` resolves only once the `AudioBrowser` pod is linked. Run `pod install` (from `ios/`) after adding the npm dependency, and confirm the pod is named **`AudioBrowser`** in your `Podfile.lock`. A "module 'AudioBrowser' not found" error almost always means `pod install` hasn't run.
518
+ :::
519
+
520
+ ### 4. AppDelegate intent handler
521
+
522
+ For in-app intent handling, implement `application(_:handlerFor:)` on your `AppDelegate`, boot RN headless (the [`startReactNativeHeadless()`](#the-headless-helper) helper from the host setup), and return the library's handler object. This is the same method already shown (commented "Siri only") in the [assembled `AppDelegate`](#the-assembled-appdelegate) — add it once, not twice; it's repeated here in isolation:
523
+
524
+ ```swift
525
+ import Intents
526
+
527
+ func application(
528
+ _ application: UIApplication,
529
+ handlerFor intent: INIntent
530
+ ) -> Any? {
531
+ // The intent may have cold-launched us with no scene — boot RN first;
532
+ // the library's handler waits for RN before resolving.
533
+ startReactNativeHeadless()
534
+ return RNABAudioBrowser.handler(for: intent)
535
+ }
536
+ ```
537
+
538
+ `RNABAudioBrowser.handler(for:)` is the library's **only** intent entry point. It returns an opaque handler object for intents it supports (`INPlayMediaIntent`, `INUpdateMediaAffinityIntent`, `INAddMediaIntent`) and `nil` for anything else — there is no separate registration or setup call. The library resolves the spoken query through your configured [`search`](/guide/search) source (the same one your in-app search uses — it is **not** a browse route), queues the results, and starts playback automatically.
539
+
540
+ ::: tip Cold-launch ordering is handled for you
541
+ You don't need to race `configureBrowser` against the intent. The handler **waits for your configured `search` source (and any [Gate](/guide/gate) — a paywall/region check that can intercept playback — if you use one) to be ready** before resolving — so even on a cold launch with RN not yet booted, the query resolves only after your JS has run `configureBrowser`. Just call it as a top-level side-effect (see [Where to call `configureBrowser`](#where-to-call-configurebrowser)).
542
+ :::
543
+
544
+ > **Why a handler object, not `application(_:handle:completionHandler:)`?** The latter is not a real `UIApplicationDelegate` method, so iOS never calls it. With no in-app handler registered, the system falls back to a `UISIntentForwardingAction`, which crashes in `-[INHandleIntentForwardingActionResponse isSuccess]`. Returning a handler from `application(_:handlerFor:)` keeps iOS on the in-app path. **No separate Intents Extension is required** — everything runs in-app.
545
+
546
+ ### How It Works
547
+
548
+ 1. User taps the search button on a CarPlay tab
549
+ 2. Siri activates and listens for a voice query
550
+ 3. iOS creates an `INPlayMediaIntent` carrying the parsed query
551
+ 4. The system asks your app for a handler via `application(_:handlerFor:)` and drives the intent against it
552
+ 5. The library resolves the request and starts playback — **resuming** the last session when the user just said your app's name, otherwise **searching** your content (genre/artist/album/etc. are forwarded as structured search params)
553
+ 6. CarPlay's Now Playing screen updates automatically
554
+
555
+ ## Loading States
556
+
557
+ ::: tip Optional browse polish
558
+ This and Album Line Navigation below are optional refinements to the browse templates — skip them for a minimal integration.
559
+ :::
560
+
561
+ Browse screens show a loading state while their content resolves — pushed destinations, tabs loading lazily, and the startup screen while tabs are queried. On iOS 18.4+ the system loading spinner is shown automatically. Older iOS versions have no spinner API; those screens stay blank unless you supply a [`carPlayLoadingTitle`](/api/types/browser/#carplayloadingtitle):
562
+
563
+ ```ts
564
+ configureBrowser({
565
+ carPlayLoadingTitle: t('loading')
566
+ // ...
567
+ })
568
+ ```
569
+
570
+ The title renders as the list's centered empty state and disappears as soon as content arrives.
571
+
572
+ ## Album Line Navigation
573
+
574
+ The album line on the Now Playing screen can navigate into the browse tree. Two things are needed for it to become tappable: the track must have an **`album`** string (CarPlay builds the tappable line from it — see the warning below), and an **`albumPath`** pointing at a browse path (a `path` in your [`routes`](/guide/basic-usage), so the destination must exist there). Set `albumPath` to make the album line tappable while that track is active — tapping pushes that path:
575
+
576
+ ```ts
577
+ {
578
+ title: 'Sweet Pea',
579
+ artist: 'The Radishes',
580
+ album: 'Greens',
581
+ src: 'https://…',
582
+ albumPath: '/album/greens'
583
+ }
584
+ ```
585
+
586
+ For tracks without an `albumPath`, configure a [`resolveAlbumPath`](/api/types/browser/#resolvealbumpath) resolver. It runs when the active track changes (not at tap time), so the album line only becomes tappable when there is actually somewhere to go:
587
+
588
+ ```ts
589
+ configureBrowser({
590
+ // `slugify` here is your own helper (illustrative) — turn an album name
591
+ // into a browse path that exists in your `routes`.
592
+ resolveAlbumPath: (track) =>
593
+ track.album ? `/album/${slugify(track.album)}` : undefined
594
+ // ...
595
+ })
596
+ ```
597
+
598
+ Return a browse path to enable the album line for that track, or `undefined` to leave it untappable.
599
+
600
+ ::: warning Album metadata required
601
+ CarPlay renders the tappable album/artist button as a **separate line built from the track's `album`** (visible as the third metadata line, with a chevron). Tracks without an `album` have no string for CarPlay to turn into a button — the artist line alone is never tappable. Set `album` on any track that should offer album-line navigation. See [Now Playing](/guide/now-playing) for the full field-by-surface rendering matrix.
602
+ :::
603
+
604
+ ## Testing
605
+
606
+ Test your app in the `CarPlay Simulator.app`, which is part of the [Additional Tools for Xcode](https://developer.apple.com/download/all/?q=additional%20tools%20for%20xcode). Siri voice search works there, so you can exercise the full intent flow without a car. CarPlay support on the iOS Simulator can be a little flaky — if something looks broken, retry or fall back to a physical device for final verification.
607
+
608
+ ### Troubleshooting
609
+
610
+ | Symptom | Likely cause |
611
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
612
+ | Siri mic opens but returns nothing | No [`search` source](#siri-voice-search) configured (Step 0). |
613
+ | The "Ask Siri" cell does nothing | Siri entitlement / Info.plist keys / intent handler not wired (steps 1–4). |
614
+ | CarPlay browse screens hang empty | `startReactNativeHeadless` missing or misnamed — the library calls it by exact selector. |
615
+ | Build error: _module 'AudioBrowser' not found_ | `pod install` hasn't linked the `AudioBrowser` pod. |
616
+ | Crash: _no UISceneConfiguration named …_ | The `name:` in `configurationForConnecting` doesn't match `UISceneConfigurationName` in the manifest. |
617
+
618
+ ## Reference
619
+
620
+ See the [example app](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native/ios/AudioBrowserExample) for a complete working implementation.