react-native-audio-browser 0.2.0-next.11 → 0.2.0-next.13

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 (416) hide show
  1. package/README.md +14 -4
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +115 -26
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +262 -188
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +23 -3
  6. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  7. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +95 -58
  8. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +68 -26
  9. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +60 -0
  10. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +19 -7
  11. package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
  12. package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  14. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +120 -67
  15. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -24
  16. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +109 -74
  17. package/android/src/main/java/com/audiobrowser/player/Player.kt +51 -30
  18. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  19. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  20. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +44 -40
  21. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  22. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +108 -0
  23. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +52 -53
  24. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +74 -41
  25. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  26. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +229 -50
  27. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  28. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  29. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +1 -1
  30. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +147 -0
  31. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -53
  32. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +154 -0
  33. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  34. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +1 -2
  36. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  38. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  39. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  40. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +161 -0
  41. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  42. package/ios/Browser/BrowserConfig.swift +102 -5
  43. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  44. package/ios/Browser/BrowserManager.swift +229 -180
  45. package/ios/Browser/BrowserPathHelper.swift +44 -44
  46. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  47. package/ios/Browser/JsonModels.swift +258 -106
  48. package/ios/Browser/SearchDrillIn.swift +6 -6
  49. package/ios/Browser/SectionPresentation.swift +64 -0
  50. package/ios/Browser/SectionScope.swift +58 -24
  51. package/ios/Browser/StyleResolver.swift +58 -0
  52. package/ios/Browser/TabBarEntries.swift +9 -3
  53. package/ios/Browser/TrackIdTemplate.swift +37 -0
  54. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  55. package/ios/CarPlay/CarPlayController.swift +227 -85
  56. package/ios/CarPlay/CarPlayImageLoader.swift +38 -18
  57. package/ios/CarPlay/CarPlayListItemFactory.swift +289 -84
  58. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  59. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  60. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  61. package/ios/Extension/ResolvedTrack+Copying.swift +11 -15
  62. package/ios/Extension/Section+Copying.swift +31 -0
  63. package/ios/Extension/Track+Copying.swift +6 -12
  64. package/ios/Extension/Track+Identity.swift +18 -0
  65. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  66. package/ios/Http/HttpClient.swift +8 -2
  67. package/ios/HybridAudioBrowser.swift +166 -45
  68. package/ios/Model/NitroTypeStubs.swift +106 -21
  69. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  70. package/ios/Player/MediaIntentCriteria.swift +13 -5
  71. package/ios/Player/MediaLoader.swift +33 -9
  72. package/ios/Player/NowPlayingUpdater.swift +54 -6
  73. package/ios/Player/PlaybackCoordinator.swift +13 -6
  74. package/ios/Player/PlaybackTimer.swift +8 -2
  75. package/ios/Player/QueueManager.swift +36 -10
  76. package/ios/Player/RetryManager.swift +4 -3
  77. package/ios/Player/ShuffleOrder.swift +23 -0
  78. package/ios/Player/SleepTimerHandling.swift +13 -0
  79. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  80. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  81. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  82. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  83. package/ios/Tests/JsonModelsTests.swift +150 -1
  84. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  85. package/ios/Tests/QueueManagerTests.swift +72 -6
  86. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  87. package/ios/Tests/ResolvedTrackCopyingTests.swift +37 -35
  88. package/ios/Tests/SearchDrillInTests.swift +16 -16
  89. package/ios/Tests/SectionPresentationTests.swift +90 -0
  90. package/ios/Tests/SectionScopeTests.swift +87 -55
  91. package/ios/Tests/StyleResolverTests.swift +127 -0
  92. package/ios/Tests/Support/TestTypes.swift +7 -0
  93. package/ios/Tests/TabBarEntriesTests.swift +18 -18
  94. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  95. package/ios/Tests/TrackIdentityTests.swift +20 -0
  96. package/ios/Tests/TrackSelectorTests.swift +168 -33
  97. package/ios/TrackPlayer.swift +57 -34
  98. package/ios/TrackSelector.swift +45 -30
  99. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  100. package/ios/Util/NetworkMonitor.swift +43 -26
  101. package/ios/Util/SFSymbolRenderer.swift +24 -2
  102. package/ios/Util/SVGProcessor.swift +9 -2
  103. package/lib/commonjs/AudioBrowser.js +11 -0
  104. package/lib/commonjs/AudioBrowser.js.map +1 -1
  105. package/lib/commonjs/features/browser-config.js +11 -8
  106. package/lib/commonjs/features/browser-config.js.map +1 -1
  107. package/lib/commonjs/features/browser.js +2 -2
  108. package/lib/commonjs/features/favorites.js +13 -10
  109. package/lib/commonjs/features/favorites.js.map +1 -1
  110. package/lib/commonjs/features/playback/controls.js +4 -2
  111. package/lib/commonjs/features/playback/controls.js.map +1 -1
  112. package/lib/commonjs/features/playback/progress.js +2 -1
  113. package/lib/commonjs/features/playback/progress.js.map +1 -1
  114. package/lib/commonjs/features/playback/rate.js +8 -1
  115. package/lib/commonjs/features/playback/rate.js.map +1 -1
  116. package/lib/commonjs/features/playback/state.js +2 -3
  117. package/lib/commonjs/features/playback/state.js.map +1 -1
  118. package/lib/commonjs/features/player/options.js.map +1 -1
  119. package/lib/commonjs/features/player/setup.js +3 -1
  120. package/lib/commonjs/features/player/setup.js.map +1 -1
  121. package/lib/commonjs/features/player/validateOptions.js +2 -0
  122. package/lib/commonjs/features/player/validateOptions.js.map +1 -1
  123. package/lib/commonjs/features/queue/activeTrack.js +14 -3
  124. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  125. package/lib/commonjs/features/queue/queue.js +11 -4
  126. package/lib/commonjs/features/queue/queue.js.map +1 -1
  127. package/lib/commonjs/utils/LazyNativeEmitter.js +21 -10
  128. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  129. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  130. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  131. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  132. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  133. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  134. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  135. package/lib/commonjs/web/NativeAudioBrowser.js +32 -24
  136. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  137. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  138. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  139. package/lib/commonjs/web/browser/BrowserManager.js +144 -87
  140. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  141. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  142. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  143. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  144. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  145. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  146. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  147. package/lib/commonjs/web/browser/sections.js +89 -0
  148. package/lib/commonjs/web/browser/sections.js.map +1 -0
  149. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  150. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  151. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  152. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  153. package/lib/module/AudioBrowser.js +1 -0
  154. package/lib/module/AudioBrowser.js.map +1 -1
  155. package/lib/module/features/browser-config.js +11 -8
  156. package/lib/module/features/browser-config.js.map +1 -1
  157. package/lib/module/features/browser.js +2 -2
  158. package/lib/module/features/favorites.js +13 -10
  159. package/lib/module/features/favorites.js.map +1 -1
  160. package/lib/module/features/playback/controls.js +4 -2
  161. package/lib/module/features/playback/controls.js.map +1 -1
  162. package/lib/module/features/playback/progress.js +2 -1
  163. package/lib/module/features/playback/progress.js.map +1 -1
  164. package/lib/module/features/playback/rate.js +8 -1
  165. package/lib/module/features/playback/rate.js.map +1 -1
  166. package/lib/module/features/playback/state.js +2 -3
  167. package/lib/module/features/playback/state.js.map +1 -1
  168. package/lib/module/features/player/options.js.map +1 -1
  169. package/lib/module/features/player/setup.js +3 -1
  170. package/lib/module/features/player/setup.js.map +1 -1
  171. package/lib/module/features/player/validateOptions.js +2 -0
  172. package/lib/module/features/player/validateOptions.js.map +1 -1
  173. package/lib/module/features/queue/activeTrack.js +14 -3
  174. package/lib/module/features/queue/activeTrack.js.map +1 -1
  175. package/lib/module/features/queue/queue.js +11 -4
  176. package/lib/module/features/queue/queue.js.map +1 -1
  177. package/lib/module/utils/LazyNativeEmitter.js +21 -10
  178. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  179. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  180. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  181. package/lib/module/utils/getTrackIdentity.js +20 -0
  182. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  183. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  184. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  185. package/lib/module/web/NativeAudioBrowser.js +32 -24
  186. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  187. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  188. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  189. package/lib/module/web/browser/BrowserManager.js +144 -87
  190. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  191. package/lib/module/web/browser/FavoriteManager.js +31 -21
  192. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  193. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  194. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  195. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  196. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  197. package/lib/module/web/browser/sections.js +83 -0
  198. package/lib/module/web/browser/sections.js.map +1 -0
  199. package/lib/module/web/player/NowPlayingManager.js +4 -3
  200. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  201. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  202. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  203. package/lib/typescript/src/AudioBrowser.d.ts +1 -0
  204. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  205. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  206. package/lib/typescript/src/features/browser.d.ts +2 -2
  207. package/lib/typescript/src/features/favorites.d.ts +13 -10
  208. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  209. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  210. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  211. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  212. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  213. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  214. package/lib/typescript/src/features/playback/state.d.ts +2 -3
  215. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  216. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  217. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  218. package/lib/typescript/src/features/player/options.d.ts +21 -19
  219. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  220. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  221. package/lib/typescript/src/features/player/validateOptions.d.ts +1 -8
  222. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -1
  223. package/lib/typescript/src/features/queue/activeTrack.d.ts +8 -1
  224. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  225. package/lib/typescript/src/features/queue/queue.d.ts +5 -2
  226. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  227. package/lib/typescript/src/types/browser-native.d.ts +2 -2
  228. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  229. package/lib/typescript/src/types/browser-nodes.d.ts +361 -126
  230. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  231. package/lib/typescript/src/types/browser.d.ts +33 -53
  232. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  233. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +16 -17
  234. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  235. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  236. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  237. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  238. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  239. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  240. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  241. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +0 -1
  242. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  243. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +8 -3
  244. package/nitrogen/generated/android/c++/JBrowseResult.hpp +16 -4
  245. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  246. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  247. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +8 -3
  248. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +8 -3
  249. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +8 -3
  250. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +8 -3
  251. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +8 -3
  252. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +8 -3
  253. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +8 -2
  254. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +8 -2
  255. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +16 -4
  256. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +8 -3
  257. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +2 -5
  258. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +8 -3
  259. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +15 -3
  260. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +8 -2
  261. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  262. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +33 -14
  263. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  264. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  265. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +8 -3
  266. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +22 -10
  267. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +14 -2
  268. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +10 -7
  269. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +2 -5
  270. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  271. package/nitrogen/generated/android/c++/JOptions.hpp +2 -5
  272. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +8 -3
  273. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  274. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +55 -51
  275. package/nitrogen/generated/android/c++/JSection.hpp +120 -0
  276. package/nitrogen/generated/android/c++/JSectionStyle.hpp +96 -0
  277. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  278. package/nitrogen/generated/android/c++/JTrack.hpp +23 -48
  279. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +8 -2
  280. package/nitrogen/generated/android/c++/JTrackStyle.hpp +46 -18
  281. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +16 -4
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +5 -5
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -18
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → Section.kt} +18 -6
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +59 -0
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -15
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +35 -5
  297. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +158 -82
  298. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +25 -10
  299. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +25 -10
  300. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  301. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  302. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  303. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  304. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  305. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  306. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +5 -5
  307. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  308. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  309. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +54 -83
  310. package/nitrogen/generated/ios/swift/Section.swift +100 -0
  311. package/nitrogen/generated/ios/swift/SectionStyle.swift +133 -0
  312. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  313. package/nitrogen/generated/ios/swift/Track.swift +20 -73
  314. package/nitrogen/generated/ios/swift/TrackStyle.swift +86 -22
  315. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  316. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  317. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  318. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  319. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  320. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  321. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -6
  322. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  323. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  324. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +30 -38
  325. package/nitrogen/generated/shared/c++/Section.hpp +106 -0
  326. package/nitrogen/generated/shared/c++/SectionStyle.hpp +126 -0
  327. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  328. package/nitrogen/generated/shared/c++/Track.hpp +15 -31
  329. package/nitrogen/generated/shared/c++/TrackStyle.hpp +73 -34
  330. package/package.json +7 -2
  331. package/src/AudioBrowser.ts +1 -0
  332. package/src/features/browser-config.test.ts +21 -6
  333. package/src/features/browser-config.ts +11 -8
  334. package/src/features/browser.test.ts +1 -1
  335. package/src/features/browser.ts +2 -2
  336. package/src/features/favorites.ts +13 -10
  337. package/src/features/hook-wiring.test.ts +63 -15
  338. package/src/features/playback/controls.ts +4 -2
  339. package/src/features/playback/progress.ts +2 -1
  340. package/src/features/playback/rate.ts +8 -1
  341. package/src/features/playback/state.ts +2 -3
  342. package/src/features/playback/trackPlaybackTime.ts +1 -1
  343. package/src/features/player/options.ts +27 -19
  344. package/src/features/player/setup.test.ts +12 -3
  345. package/src/features/player/setup.ts +3 -1
  346. package/src/features/player/validateOptions.ts +2 -0
  347. package/src/features/queue/activeTrack.ts +14 -3
  348. package/src/features/queue/queue.ts +11 -4
  349. package/src/nitro-stub-drift.test.ts +40 -0
  350. package/src/public-surface.test.ts +40 -0
  351. package/src/specs/audio-browser.nitro.ts +4 -3
  352. package/src/style-resolution.test.ts +210 -0
  353. package/src/types/browser-native.ts +2 -2
  354. package/src/types/browser-nodes.ts +377 -135
  355. package/src/types/browser.ts +34 -55
  356. package/src/utils/LazyNativeEmitter.ts +20 -10
  357. package/src/utils/NativeUpdatedValue.ts +29 -13
  358. package/src/utils/getTrackIdentity.ts +20 -0
  359. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  360. package/src/web/NativeAudioBrowser.ts +37 -22
  361. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  362. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  363. package/src/web/browser/BrowserManager.ts +176 -95
  364. package/src/web/browser/FavoriteManager.test.ts +84 -0
  365. package/src/web/browser/FavoriteManager.ts +28 -23
  366. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  367. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  368. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  369. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  370. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  371. package/src/web/browser/sections.test.ts +91 -0
  372. package/src/web/browser/sections.ts +72 -0
  373. package/src/web/player/NowPlayingManager.ts +4 -3
  374. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  375. package/src/web/util/BrowserPathHelper.ts +61 -13
  376. package/website/guide/android-auto.md +114 -0
  377. package/website/guide/android-certificates.md +409 -0
  378. package/website/guide/artwork.md +255 -0
  379. package/website/guide/audio-output.md +135 -0
  380. package/website/guide/automotive.md +86 -0
  381. package/website/guide/basic-usage.md +151 -0
  382. package/website/guide/battery.md +115 -0
  383. package/website/guide/browser.md +581 -0
  384. package/website/guide/carplay.md +620 -0
  385. package/website/guide/configuration.md +173 -0
  386. package/website/guide/equalizer.md +231 -0
  387. package/website/guide/errors.md +310 -0
  388. package/website/guide/favorites.md +154 -0
  389. package/website/guide/gate.md +143 -0
  390. package/website/guide/getting-started.md +46 -0
  391. package/website/guide/hooks.md +238 -0
  392. package/website/guide/live-streams.md +125 -0
  393. package/website/guide/metadata.md +239 -0
  394. package/website/guide/migrating-from-track-player.md +244 -0
  395. package/website/guide/native-callback-fetch.md +127 -0
  396. package/website/guide/network.md +86 -0
  397. package/website/guide/now-playing.md +175 -0
  398. package/website/guide/playback.md +328 -0
  399. package/website/guide/queue.md +300 -0
  400. package/website/guide/remote-controls.md +256 -0
  401. package/website/guide/search.md +255 -0
  402. package/website/guide/sleep-timer.md +268 -0
  403. package/website/guide/track.md +246 -0
  404. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  405. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  406. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  407. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  408. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  409. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  410. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  411. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  412. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  413. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  414. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  415. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  416. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
@@ -26,9 +26,22 @@ public final class RNABCarPlayController: NSObject {
26
26
  private var trackSelector: TrackSelector?
27
27
 
28
28
  /// Track content subscriptions
29
- private var isStarted = false
30
29
  private var listenerRemovals: [() -> Void] = []
31
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
+
32
45
  /// Paths whose loading template has been pushed but not yet appeared. Guards a
33
46
  /// rapid double-tap from pushing the same destination twice (the first push is
34
47
  /// async, so `topTemplate` may not reflect it yet). Cleared when it appears.
@@ -89,21 +102,37 @@ public final class RNABCarPlayController: NSObject {
89
102
  audioBrowser?.browserManager.config ?? BrowserConfig()
90
103
  }
91
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
+
92
126
  /// Whether a list item identifies the currently active (loaded) track.
93
127
  ///
94
- /// Identity is the stable `Track.id` when both sides carry one — the active
128
+ /// Compares track identities (`id` when non-blank, else `src`): the active
95
129
  /// track may have been loaded outside the browse tree (the consumer's own
96
130
  /// `load`/`setQueue`) with a `src` that differs textually from the browse
97
131
  /// row's (absolute vs relative, extra query params) while being the same
98
- /// item. Exact `src` equality remains the fallback for consumers that don't
99
- /// assign ids.
100
- private func isActiveTrack(id: String?, src: String?) -> Bool {
101
- guard let currentTrack = audioBrowser?.getPlayer()?.currentTrack else { return false }
102
- if let id, !id.isEmpty, let currentId = currentTrack.id, !currentId.isEmpty {
103
- return id == currentId
104
- }
105
- guard let src else { return false }
106
- return currentTrack.src == src
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
107
136
  }
108
137
 
109
138
  // MARK: - Initialization
@@ -118,8 +147,8 @@ public final class RNABCarPlayController: NSObject {
118
147
  self?.listItemFactory?.createListItem(for: track, handler: handler)
119
148
  ?? CPListItem(text: track.title, detailText: nil)
120
149
  }
121
- nowPlayingManager.navigateToUrl = { [weak self] url, title in
122
- Task { @MainActor in await self?.navigateToUrl(url, title: title) }
150
+ nowPlayingManager.navigateToPath = { [weak self] path, title in
151
+ Task { @MainActor in await self?.navigateToPath(path, title: title) }
123
152
  }
124
153
  }
125
154
 
@@ -137,7 +166,9 @@ public final class RNABCarPlayController: NSObject {
137
166
  @objc
138
167
  public func start() {
139
168
  guard !isStarted else { return }
140
- isStarted = true
169
+ runCounter &+= 1
170
+ currentRun = runCounter
171
+ let run = runCounter
141
172
 
142
173
  logger.info("Starting CarPlay controller")
143
174
 
@@ -164,7 +195,8 @@ public final class RNABCarPlayController: NSObject {
164
195
  // Wait for both browser and player to be ready
165
196
  Task { @MainActor in
166
197
  let (browser, _) = await playerAndConfiguredBrowser.wait()
167
- guard self.isStarted else { return }
198
+ // Answers "still running?" and "still this run?" at once.
199
+ guard self.currentRun == run else { return }
168
200
  self.logger.debug("AudioBrowser and player ready, setting up CarPlay")
169
201
  self.audioBrowser = browser
170
202
  self.isGated = browser.isGateActive
@@ -178,12 +210,13 @@ public final class RNABCarPlayController: NSObject {
178
210
 
179
211
  // Create list item factory
180
212
  let factory = CarPlayListItemFactory(
181
- isActiveTrack: { [weak self] id, src in self?.isActiveTrack(id: id, src: src) ?? false },
213
+ isActiveTrack: { [weak self] identity in self?.isActiveTrack(identity: identity) ?? false },
182
214
  onItemSelected: { [weak self] track, completion in
183
215
  self?.handleItemSelection(track: track, completion: completion)
184
216
  },
185
217
  )
186
218
  factory.imageLoader = self.imageLoader
219
+ factory.playingIndicatorLocation = self.playingIndicatorLocation
187
220
  self.listItemFactory = factory
188
221
 
189
222
  self.setupContentSubscriptions()
@@ -198,7 +231,7 @@ public final class RNABCarPlayController: NSObject {
198
231
  @objc
199
232
  public func stop() {
200
233
  guard isStarted else { return }
201
- isStarted = false
234
+ currentRun = nil
202
235
 
203
236
  logger.info("Stopping CarPlay controller")
204
237
 
@@ -261,7 +294,7 @@ public final class RNABCarPlayController: NSObject {
261
294
  }
262
295
 
263
296
  // Subscribe to config changes (for Now Playing buttons and per-track
264
- // button state — e.g. resolveAlbumUrl appearing/disappearing)
297
+ // button state — e.g. resolveAlbumPath appearing/disappearing)
265
298
  audioBrowser.browserManager.onConfigChanged = { [weak self] _ in
266
299
  Task { @MainActor in
267
300
  self?.nowPlayingManager.setupNowPlayingButtons()
@@ -269,13 +302,22 @@ public final class RNABCarPlayController: NSObject {
269
302
  }
270
303
  }
271
304
 
272
- // Now-playing buttons + Up Next are runtime-updatable via updateOptions();
273
- // refresh them when player options change (configureBrowser is no longer
274
- // the only path that can change them).
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).
275
308
  let optionsToken = audioBrowser.playerOptionsChangedEmitter.addListener { [weak self] in
276
309
  Task { @MainActor in
277
- self?.nowPlayingManager.setupNowPlayingButtons()
278
- self?.nowPlayingManager.updateNowPlayingButtonStates()
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
+ }
279
321
  }
280
322
  }
281
323
  listenerRemovals.append { [weak audioBrowser] in
@@ -409,9 +451,7 @@ public final class RNABCarPlayController: NSObject {
409
451
  private func buildInitialInterface() async {
410
452
  guard let audioBrowser else {
411
453
  logger.error("AudioBrowser not available")
412
- await showRootNavigationError(
413
- NavigationError(code: .unknownError, message: "", statusCode: nil, statusCodeSuccess: nil),
414
- )
454
+ await showRootNavigationError(.code(.unknownError))
415
455
  return
416
456
  }
417
457
 
@@ -430,16 +470,10 @@ public final class RNABCarPlayController: NSObject {
430
470
  showLoadingTemplate()
431
471
  }
432
472
  do {
473
+ // An empty query result routes to the .emptyContent error page inside
474
+ // showTabBar, shared with runtime tab changes.
433
475
  let queriedTabs = try await audioBrowser.browserManager.queryTabs()
434
- if !queriedTabs.isEmpty {
435
- await showTabBar(tabs: queriedTabs)
436
- } else {
437
- // Empty is a navigation error (.emptyContent) so it formats through
438
- // the app's formatNavigationError like every other failure (ADR 0001).
439
- await showRootNavigationError(
440
- NavigationError(code: .emptyContent, message: "", statusCode: nil, statusCodeSuccess: nil),
441
- )
442
- }
476
+ await showTabBar(tabs: queriedTabs)
443
477
  } catch {
444
478
  logger.error("Failed to query tabs: \(error.localizedDescription)")
445
479
  await showRootNavigationError(NavigationError.from(error))
@@ -459,7 +493,9 @@ public final class RNABCarPlayController: NSObject {
459
493
  // MARK: - Tab Bar
460
494
 
461
495
  @MainActor
462
- private func showTabBar(tabs: [Track]) async {
496
+ private func showTabBar(tabs allTabs: [Track]) async {
497
+ let tabs = Self.visibleTabs(allTabs)
498
+
463
499
  // Serialize concurrent tab-bar builds across BOTH the gated and non-gated
464
500
  // paths with one shared generation token: bump at entry, capture, and bail
465
501
  // before any template mutation once a newer build supersedes us. Both paths
@@ -477,11 +513,27 @@ public final class RNABCarPlayController: NSObject {
477
513
  // the gate page with that request's chrome; an allowed tab shows real
478
514
  // content. This generalizes the old "all tabs show the gate" — a path the
479
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.
480
522
  if isGated {
481
523
  await showGatedTabBar(tabs: tabs, generation: generation)
482
524
  return
483
525
  }
484
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
+
485
537
  logger.info("Building tab bar with \(tabs.count) tabs")
486
538
 
487
539
  let maxTabs = CPTabBarTemplate.maximumTabCount
@@ -501,8 +553,8 @@ public final class RNABCarPlayController: NSObject {
501
553
  interfaceController.safeSetRoot(tabBar, animated: true)
502
554
 
503
555
  // Load content for the first tab only - others load lazily when selected
504
- if let firstTemplate = tabTemplates.first, let firstTab = tabs.first, let url = firstTab.url {
505
- await loadContent(for: url, into: firstTemplate)
556
+ if let firstTemplate = tabTemplates.first, let firstTab = tabs.first, let path = firstTab.path {
557
+ await loadContent(for: path, into: firstTemplate)
506
558
  }
507
559
  }
508
560
 
@@ -521,8 +573,7 @@ public final class RNABCarPlayController: NSObject {
521
573
 
522
574
  guard !tabs.isEmpty else {
523
575
  // Tabs unknown (config not loaded yet, or none) — resolve the root path
524
- // and show a single gate page (or, if allowed, fall back to a normal
525
- // build once tabs arrive).
576
+ // and show a single gate page.
526
577
  let outcome = await audioBrowser.gateDecision(
527
578
  for: NativeGateRequest(reason: .browse, path: nil, search: nil),
528
579
  )
@@ -532,6 +583,11 @@ public final class RNABCarPlayController: NSObject {
532
583
  interfaceController.safeSetRoot(
533
584
  makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
534
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))
535
591
  }
536
592
  return
537
593
  }
@@ -540,7 +596,7 @@ public final class RNABCarPlayController: NSObject {
540
596
  var templates: [CPListTemplate] = []
541
597
  for tab in limitedTabs {
542
598
  let outcome = await audioBrowser.gateDecision(
543
- for: NativeGateRequest(reason: .browse, path: tab.url, search: nil),
599
+ for: NativeGateRequest(reason: .browse, path: tab.path, search: nil),
544
600
  )
545
601
  guard gateBuildGeneration == generation else { return } // superseded by a newer build
546
602
  if outcome.gated {
@@ -566,8 +622,8 @@ public final class RNABCarPlayController: NSObject {
566
622
  // Eagerly fill any allowed (content) tabs: templateDidAppear isn't
567
623
  // guaranteed to re-fire for templates swapped in via updateTemplates.
568
624
  for (tab, template) in zip(limitedTabs, templates) {
569
- guard getPath(from: template) != nil, let url = tab.url else { continue }
570
- await loadContent(for: url, into: template)
625
+ guard getPath(from: template) != nil, let path = tab.path else { continue }
626
+ await loadContent(for: path, into: template)
571
627
  }
572
628
  }
573
629
 
@@ -576,7 +632,7 @@ public final class RNABCarPlayController: NSObject {
576
632
  /// until its content lazy-loads on first appearance.
577
633
  private func createTabTemplate(for track: Track) -> CPListTemplate {
578
634
  // The path stored on the template drives lazy loading and refresh.
579
- let template = makeLoadingTemplate(title: track.title, path: track.url)
635
+ let template = makeLoadingTemplate(title: track.title, path: track.path)
580
636
  applyTabBarEntry(to: template, for: track)
581
637
  return template
582
638
  }
@@ -682,14 +738,13 @@ public final class RNABCarPlayController: NSObject {
682
738
  // even with no rows — the "Ask Siri to Play" cell IS its content — so it
683
739
  // renders rather than falling through to the empty-content state.
684
740
  let hasAssistantCell = resolved.carPlaySiriListButton != nil
685
- if resolved.children?.isEmpty ?? true, !hasAssistantCell {
741
+ // A page of empty sections is still empty.
742
+ let hasChildren = resolved.normalizedSections?.contains { !$0.children.isEmpty } ?? false
743
+ if !hasChildren, !hasAssistantCell {
686
744
  // Empty is modeled as a navigation error (code .emptyContent) so it goes
687
745
  // through the same path-aware formatter as failures — letting an app give
688
746
  // an empty Favorites tab different copy than an empty search. ADR 0001.
689
- let empty = NavigationError(
690
- code: .emptyContent, message: "", statusCode: nil, statusCodeSuccess: nil,
691
- )
692
- await showNavigationErrorView(empty, path: path, on: template)
747
+ await showNavigationErrorView(.code(.emptyContent), path: path, on: template)
693
748
  } else {
694
749
  updateTemplate(template, with: resolved)
695
750
  }
@@ -749,7 +804,7 @@ public final class RNABCarPlayController: NSObject {
749
804
  }
750
805
 
751
806
  // If this track is already loaded, resume playback and show Now Playing.
752
- if track.src != nil, isActiveTrack(id: track.id, src: track.src) {
807
+ if track.src != nil, isActiveTrack(identity: track.identity) {
753
808
  try? audioBrowser.play()
754
809
  nowPlayingManager.showNowPlaying()
755
810
  completion()
@@ -777,7 +832,7 @@ public final class RNABCarPlayController: NSObject {
777
832
 
778
833
  // Release CarPlay immediately so the list never locks up. Apple's handler
779
834
  // guidance is to finish processing the tap promptly; for a browse we "finish"
780
- // by pushing the destination and filling it in (see navigateToUrl), and for
835
+ // by pushing the destination and filling it in (see navigateToPath), and for
781
836
  // playback the Now Playing surface owns its own loading state.
782
837
  completion()
783
838
 
@@ -790,8 +845,8 @@ public final class RNABCarPlayController: NSObject {
790
845
  self.nowPlayingManager.showNowPlaying(popToFront: false)
791
846
  case .intercepted:
792
847
  self.nowPlayingManager.showNowPlaying(popToFront: false)
793
- case let .browse(url):
794
- await self.navigateToUrl(url, title: track.title)
848
+ case let .browse(path):
849
+ await self.navigateToPath(path, title: track.title)
795
850
  case .none:
796
851
  break
797
852
  }
@@ -809,53 +864,93 @@ public final class RNABCarPlayController: NSObject {
809
864
  }
810
865
  }
811
866
 
812
- /// Pushes a browsable URL's destination immediately as an empty, spinning list
867
+ /// Pushes a browsable path's destination immediately as an empty, spinning list
813
868
  /// template — so the list the user tapped from is never blocked. The content is
814
869
  /// filled by `templateDidAppear` → `loadContentIfNeeded` → `loadContent`, which
815
870
  /// runs once the template is on screen (the timing CarPlay needs: updates made
816
871
  /// right after a push are dropped). Backing out and re-tapping retries.
872
+ ///
873
+ /// One exception to pushing: a path that IS a tab selects that tab (see
874
+ /// `selectTab`).
817
875
  @MainActor
818
- private func navigateToUrl(_ url: String, title: String) async {
876
+ private func navigateToPath(_ path: String, title: String) async {
819
877
  // The gate is resolved per-request: a gated path pushes the gate page
820
878
  // (with this request's chrome) instead of browsing into content; this also
821
879
  // blocks indirect entries like the Now Playing album line. An allowed path
822
880
  // browses normally even while a gate is active.
823
881
  if isGated, let audioBrowser {
824
882
  let outcome = await audioBrowser.gateDecision(
825
- for: NativeGateRequest(reason: .browse, path: url, search: nil),
883
+ for: NativeGateRequest(reason: .browse, path: path, search: nil),
826
884
  )
827
885
  if outcome.gated {
828
886
  audioBrowser.onGate(GateEvent(reason: .browse))
829
- if let top = interfaceController.topTemplate, getPath(from: top) == url { return }
887
+ if let top = interfaceController.topTemplate, getPath(from: top) == path { return }
830
888
  interfaceController.safePush(
831
889
  makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
832
890
  )
833
891
  return
834
892
  }
835
893
  }
894
+ // A link to a tab's own path switches to that tab instead of pushing a
895
+ // second copy of it into the current tab's stack.
896
+ if selectTab(owning: path) { return }
836
897
  // Avoid pushing a duplicate if the top template already shows this path.
837
- if let top = interfaceController.topTemplate, getPath(from: top) == url {
898
+ if let top = interfaceController.topTemplate, getPath(from: top) == path {
838
899
  return
839
900
  }
840
901
  // …and guard a rapid double-tap whose first push hasn't appeared yet (so the
841
902
  // check above can't see it). Cleared when the pushed template appears.
842
- guard !navigatingPaths.contains(url) else { return }
843
- navigatingPaths.insert(url)
903
+ guard !navigatingPaths.contains(path) else { return }
904
+ navigatingPaths.insert(path)
844
905
 
845
- let template = makeLoadingTemplate(title: title, path: url)
906
+ let template = makeLoadingTemplate(title: title, path: path)
846
907
  interfaceController.safePush(template, animated: true) { [weak self] pushed, error in
847
908
  guard !pushed else { return }
848
909
  // The push can fail (e.g. CarPlay's template stack depth limit). The
849
910
  // appear callback that normally clears the guard will never fire, so
850
911
  // clear it here — otherwise the destination stays unreachable until
851
912
  // CarPlay reconnects.
852
- self?.navigatingPaths.remove(url)
913
+ self?.navigatingPaths.remove(path)
853
914
  if let error {
854
- self?.logger.error("pushTemplate failed for \(url): \(error.localizedDescription)")
915
+ self?.logger.error("pushTemplate failed for \(path): \(error.localizedDescription)")
855
916
  }
856
917
  }
857
918
  }
858
919
 
920
+ /// Switches to the tab that owns `path`, if any — a "view all" header or row
921
+ /// pointing at a tab's own path belongs in the tab bar, not pushed on top of
922
+ /// whichever tab the user tapped from (which would strand them one level deep
923
+ /// in a list the bar already offers, with a Back button to a page they never
924
+ /// came from). Any pushed stack is popped first, so the tab opens at its root.
925
+ ///
926
+ /// Returns false — and the caller pushes as usual — when `path` is no tab,
927
+ /// when the root isn't the tab bar (the gate replaces it), or before iOS 17,
928
+ /// which has no tab-selection API.
929
+ @MainActor
930
+ private func selectTab(owning path: String) -> Bool {
931
+ guard #available(iOS 17.0, *),
932
+ let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
933
+ let tab = tabBar.templates.first(where: { getPath(from: $0) == path })
934
+ else { return false }
935
+ let isPushed = interfaceController.templates.count > 1
936
+ // Already showing: the destination is where the user asked to be, so this
937
+ // is handled — just don't animate a pop-and-select over it.
938
+ if tabBar.selectedTemplate === tab, !isPushed { return true }
939
+ logger.debug("Navigating to tab \(path) instead of pushing it")
940
+ guard isPushed else {
941
+ tabBar.select(tab)
942
+ return true
943
+ }
944
+ interfaceController.safePopToRoot(animated: true) { popped, _ in
945
+ // Selecting after a failed pop would leave the pushed stack over the
946
+ // newly selected tab; the caller has already returned, so a failure just
947
+ // leaves the user where they were.
948
+ guard popped else { return }
949
+ tabBar.select(tab)
950
+ }
951
+ return true
952
+ }
953
+
859
954
  /// Builds an empty list template for content that is still resolving.
860
955
  /// On iOS 18.4+ it shows the system spinner (`showsSpinnerWhileEmpty`). On older
861
956
  /// iOS it shows the app-localized `carPlayLoadingTitle` as the centered empty
@@ -930,16 +1025,18 @@ public final class RNABCarPlayController: NSObject {
930
1025
  @MainActor
931
1026
  private func handleTabsChanged(_ tabs: [Track]) {
932
1027
  logger.debug("Tabs changed: \(tabs.count) tabs")
933
- // Same tabs by URL while ungated → only the tab-bar entries changed
1028
+ // Compare against the list showTabBar builds templates from, not the raw one.
1029
+ let visibleTabs = Self.visibleTabs(tabs)
1030
+ // Same tabs by path while ungated → only the tab-bar entries changed
934
1031
  // (e.g. titles after a locale switch): restamp them in place. No rebuild,
935
1032
  // so the selected tab and any pushed navigation stack survive. (Gate
936
1033
  // templates carry no path, so a gated tab bar never matches here.)
937
1034
  if !isGated,
938
1035
  let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
939
- tabBar.templates.count == min(tabs.count, CPTabBarTemplate.maximumTabCount),
940
- zip(tabBar.templates, tabs).allSatisfy({ getPath(from: $0) == $1.url })
1036
+ tabBar.templates.count == min(visibleTabs.count, CPTabBarTemplate.maximumTabCount),
1037
+ zip(tabBar.templates, visibleTabs).allSatisfy({ getPath(from: $0) == $1.path })
941
1038
  {
942
- for (template, tab) in zip(tabBar.templates, tabs) {
1039
+ for (template, tab) in zip(tabBar.templates, visibleTabs) {
943
1040
  applyTabBarEntry(to: template, for: tab)
944
1041
  }
945
1042
  pendingTabs = nil
@@ -978,7 +1075,7 @@ public final class RNABCarPlayController: NSObject {
978
1075
  // This callback fires when the main browser's content changes.
979
1076
  // For CarPlay-specific refreshes (e.g., favorites), use notifyContentChanged instead.
980
1077
  guard let content else { return }
981
- refreshTemplatesForPath(content.url, with: content)
1078
+ refreshTemplatesForPath(content.path, with: content)
982
1079
  }
983
1080
 
984
1081
  @MainActor
@@ -990,9 +1087,11 @@ public final class RNABCarPlayController: NSObject {
990
1087
  nowPlayingManager.handleActiveTrackChanged()
991
1088
  }
992
1089
 
993
- /// Updates the isPlaying state on all list items based on the current active track.
1090
+ /// Updates the isPlaying state (and indicator location, which options can
1091
+ /// change at runtime) on all list items based on the current active track.
994
1092
  @MainActor
995
1093
  fileprivate func updatePlayingIndicators() {
1094
+ let location = playingIndicatorLocation
996
1095
  var templates: [CPListTemplate] = []
997
1096
 
998
1097
  if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate {
@@ -1015,16 +1114,20 @@ public final class RNABCarPlayController: NSObject {
1015
1114
  for item in section.items {
1016
1115
  guard let listItem = item as? CPListItem,
1017
1116
  let info = listItem.carPlayItemInfo,
1018
- info.src != nil
1117
+ info.identity != nil
1019
1118
  else { continue }
1020
1119
 
1021
- let isPlaying = isActiveTrack(id: info.id, src: info.src)
1120
+ let isPlaying = isActiveTrack(identity: info.identity)
1022
1121
  if listItem.isPlaying != isPlaying {
1023
1122
  logger.debug(
1024
- "Updating isPlaying for \(info.id ?? info.src ?? ""): \(listItem.isPlaying) → \(isPlaying)")
1123
+ "Updating isPlaying for \(info.identity ?? ""): \(listItem.isPlaying) → \(isPlaying)")
1025
1124
  listItem.isPlaying = isPlaying
1026
1125
  templateChanged = true
1027
1126
  }
1127
+ if listItem.playingIndicatorLocation != location {
1128
+ listItem.playingIndicatorLocation = location
1129
+ templateChanged = true
1130
+ }
1028
1131
  }
1029
1132
  }
1030
1133
  // Setting isPlaying on an already-displayed item does not reliably reach
@@ -1215,15 +1318,29 @@ public final class RNABCarPlayController: NSObject {
1215
1318
  logger.debug("Calling formatNavigationError callback...")
1216
1319
  // Call the JS callback and handle result
1217
1320
  let params = FormatNavigationErrorParams(error: error, defaultFormatted: defaultFormatted, path: path)
1321
+ // The result callbacks are @Sendable and hop back explicitly: Nitro resolves
1322
+ // its promises synchronously on the JS thread, so a plain closure would
1323
+ // inherit this type's MainActor isolation and trap on entry under Swift 6's
1324
+ // dynamic isolation check — and would otherwise drive UIKit off the main
1325
+ // thread. Same pattern as HybridAudioBrowser.handleNavigationError and
1326
+ // NowPlayingUpdater.render.
1218
1327
  formatter(params)
1219
- .then { [weak self] customDisplay in
1220
- self?.logger.debug("formatNavigationError returned: \(String(describing: customDisplay))")
1221
- self?.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
1328
+ .then { @Sendable [weak self] customDisplay in
1329
+ let description = String(describing: customDisplay)
1330
+ Task { @MainActor in
1331
+ guard let self else { return }
1332
+ self.logger.debug("formatNavigationError returned: \(description)")
1333
+ self.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
1334
+ }
1222
1335
  }
1223
- .catch { [weak self] callbackError in
1224
- self?.logger.error("formatNavigationError failed: \(callbackError)")
1225
- // On error, fall back to defaults
1226
- self?.presentErrorActionSheet(customDisplay: defaultFormatted)
1336
+ .catch { @Sendable [weak self] callbackError in
1337
+ let message = callbackError.localizedDescription
1338
+ Task { @MainActor in
1339
+ guard let self else { return }
1340
+ self.logger.error("formatNavigationError failed: \(message)")
1341
+ // On error, fall back to defaults
1342
+ self.presentErrorActionSheet(customDisplay: defaultFormatted)
1343
+ }
1227
1344
  }
1228
1345
  } else {
1229
1346
  presentErrorActionSheet(customDisplay: defaultFormatted)
@@ -1264,9 +1381,22 @@ public final class RNABCarPlayController: NSObject {
1264
1381
  /// view formatted via the app's `formatNavigationError` (path "/"), like every
1265
1382
  /// other browse failure. The empty view renders reliably here because it's the
1266
1383
  /// template's initial state at set-root time.
1384
+ ///
1385
+ /// Formatting awaits the app's `formatNavigationError` callback, so a tab-bar
1386
+ /// build can start and paint during the hop — this error root must not
1387
+ /// clobber it. Captured here rather than passed in: the `showTabBar` /
1388
+ /// `showGatedTabBar` callers hold the newest generation when they call, so
1389
+ /// capturing is equivalent for them and covers the others for free.
1390
+ ///
1391
+ /// Not airtight for `buildInitialInterface`'s two callers: that function
1392
+ /// doesn't participate in `gateBuildGeneration` and suspends at `queryTabs()`,
1393
+ /// so a build that superseded it *before* this was entered isn't caught.
1394
+ /// Closing that needs a generation captured at `buildInitialInterface` entry.
1267
1395
  @MainActor
1268
1396
  private func showRootNavigationError(_ navError: NavigationError) async {
1397
+ let generation = gateBuildGeneration
1269
1398
  let formatted = await formattedNavigationError(navError, path: "/")
1399
+ guard gateBuildGeneration == generation else { return }
1270
1400
  let template = CPListTemplate(title: nil, sections: [])
1271
1401
  template.emptyViewTitleVariants = [formatted.title]
1272
1402
  template.emptyViewSubtitleVariants = formatted.message.flatMap { $0.isEmpty ? nil : [$0] } ?? []
@@ -1336,20 +1466,32 @@ private extension RNABCarPlayController {
1336
1466
  }
1337
1467
  }
1338
1468
 
1469
+ // MARK: - NavigationError Shorthand
1470
+
1471
+ extension NavigationError {
1472
+ /// A code-only error: the user-facing copy comes from the app's
1473
+ /// formatNavigationError (ADR 0001), so no message travels with it.
1474
+ static func code(_ code: NavigationErrorType) -> NavigationError {
1475
+ NavigationError(code: code, message: "", statusCode: nil, statusCodeSuccess: nil)
1476
+ }
1477
+ }
1478
+
1339
1479
  // MARK: - UIImage Resize
1340
1480
 
1341
1481
  extension UIImage {
1342
- /// Draws the image centered within the target size, maintaining aspect ratio
1343
- func resized(to targetSize: CGSize) -> UIImage? {
1344
- UIGraphicsBeginImageContextWithOptions(targetSize, false, 0.0)
1482
+ /// Draws the image centered within the target size, maintaining aspect
1483
+ /// ratio, at the given display scale (the car screen's, not the phone's).
1484
+ func resized(to targetSize: CGSize, scale: CGFloat) -> UIImage? {
1485
+ UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
1345
1486
  defer { UIGraphicsEndImageContext() }
1346
1487
 
1347
- // Scale to fit while maintaining aspect ratio
1488
+ // Scale to fit while maintaining aspect ratio. Named `fitScale` so it does
1489
+ // not shadow the `scale` parameter (the display scale) it sits next to.
1348
1490
  let widthRatio = targetSize.width / size.width
1349
1491
  let heightRatio = targetSize.height / size.height
1350
- let scale = min(widthRatio, heightRatio)
1492
+ let fitScale = min(widthRatio, heightRatio)
1351
1493
 
1352
- let scaledSize = CGSize(width: size.width * scale, height: size.height * scale)
1494
+ let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
1353
1495
  let origin = CGPoint(
1354
1496
  x: (targetSize.width - scaledSize.width) / 2,
1355
1497
  y: (targetSize.height - scaledSize.height) / 2,