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

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 (345) hide show
  1. package/README.md +4 -4
  2. package/android/build.gradle +6 -5
  3. package/android/gradle.properties +1 -1
  4. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +110 -25
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +254 -188
  6. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +23 -3
  7. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +77 -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 +49 -0
  10. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +9 -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/player/MediaSessionCallback.kt +120 -67
  14. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -24
  15. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +93 -73
  16. package/android/src/main/java/com/audiobrowser/player/Player.kt +48 -30
  17. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  18. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +46 -5
  19. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +86 -27
  20. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  21. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +20 -6
  22. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +249 -86
  23. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +44 -40
  24. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  25. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +35 -0
  26. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +45 -53
  27. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +69 -30
  28. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  29. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
  30. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +345 -8
  31. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  32. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +1 -1
  33. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +91 -0
  34. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -53
  35. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +87 -0
  36. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  37. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  38. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +1 -2
  39. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  40. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  41. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +152 -8
  42. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +218 -10
  43. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  44. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  45. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +161 -0
  46. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  47. package/ios/Browser/BrowserConfig.swift +102 -5
  48. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  49. package/ios/Browser/BrowserManager.swift +131 -143
  50. package/ios/Browser/BrowserPathHelper.swift +44 -44
  51. package/ios/Browser/JsonModels.swift +187 -106
  52. package/ios/Browser/SearchDrillIn.swift +6 -6
  53. package/ios/Browser/SectionPresentation.swift +34 -0
  54. package/ios/Browser/SectionScope.swift +58 -24
  55. package/ios/Browser/StyleResolver.swift +47 -0
  56. package/ios/Browser/TabBarEntries.swift +2 -2
  57. package/ios/Browser/TrackIdTemplate.swift +37 -0
  58. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  59. package/ios/CarPlay/CarPlayController.swift +105 -67
  60. package/ios/CarPlay/CarPlayImageLoader.swift +38 -18
  61. package/ios/CarPlay/CarPlayListItemFactory.swift +217 -84
  62. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  63. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  64. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  65. package/ios/Extension/ResolvedTrack+Copying.swift +11 -15
  66. package/ios/Extension/Section+Copying.swift +31 -0
  67. package/ios/Extension/Track+Copying.swift +6 -12
  68. package/ios/Extension/Track+Identity.swift +18 -0
  69. package/ios/HybridAudioBrowser.swift +113 -35
  70. package/ios/Model/NitroTypeStubs.swift +64 -23
  71. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  72. package/ios/Player/NowPlayingUpdater.swift +1 -1
  73. package/ios/Player/PlaybackCoordinator.swift +2 -2
  74. package/ios/Player/RetryManager.swift +4 -3
  75. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  76. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  77. package/ios/Tests/JsonModelsTests.swift +75 -1
  78. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  79. package/ios/Tests/QueueManagerTests.swift +4 -4
  80. package/ios/Tests/ResolvedTrackCopyingTests.swift +37 -35
  81. package/ios/Tests/SearchDrillInTests.swift +16 -16
  82. package/ios/Tests/SectionPresentationTests.swift +61 -0
  83. package/ios/Tests/SectionScopeTests.swift +87 -55
  84. package/ios/Tests/StyleResolverTests.swift +67 -0
  85. package/ios/Tests/TabBarEntriesTests.swift +18 -18
  86. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  87. package/ios/Tests/TrackIdentityTests.swift +20 -0
  88. package/ios/Tests/TrackSelectorTests.swift +168 -33
  89. package/ios/TrackSelector.swift +45 -30
  90. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  91. package/lib/commonjs/AudioBrowser.js +11 -0
  92. package/lib/commonjs/AudioBrowser.js.map +1 -1
  93. package/lib/commonjs/features/browser-config.js +11 -8
  94. package/lib/commonjs/features/browser-config.js.map +1 -1
  95. package/lib/commonjs/features/browser.js +2 -2
  96. package/lib/commonjs/features/favorites.js +13 -10
  97. package/lib/commonjs/features/favorites.js.map +1 -1
  98. package/lib/commonjs/features/playback/controls.js +4 -2
  99. package/lib/commonjs/features/playback/controls.js.map +1 -1
  100. package/lib/commonjs/features/playback/state.js +2 -3
  101. package/lib/commonjs/features/playback/state.js.map +1 -1
  102. package/lib/commonjs/features/player/options.js.map +1 -1
  103. package/lib/commonjs/features/player/setup.js +3 -1
  104. package/lib/commonjs/features/player/setup.js.map +1 -1
  105. package/lib/commonjs/features/player/validateOptions.js +2 -0
  106. package/lib/commonjs/features/player/validateOptions.js.map +1 -1
  107. package/lib/commonjs/features/queue/activeTrack.js +14 -3
  108. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  109. package/lib/commonjs/features/queue/queue.js +11 -4
  110. package/lib/commonjs/features/queue/queue.js.map +1 -1
  111. package/lib/commonjs/utils/LazyNativeEmitter.js +21 -10
  112. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  113. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  114. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  115. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  116. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  117. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  118. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  119. package/lib/commonjs/web/NativeAudioBrowser.js +32 -24
  120. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  121. package/lib/commonjs/web/browser/BrowserManager.js +144 -87
  122. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  123. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  124. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  125. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  126. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  127. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  128. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  129. package/lib/commonjs/web/browser/sections.js +89 -0
  130. package/lib/commonjs/web/browser/sections.js.map +1 -0
  131. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  132. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  133. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  134. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  135. package/lib/module/AudioBrowser.js +1 -0
  136. package/lib/module/AudioBrowser.js.map +1 -1
  137. package/lib/module/features/browser-config.js +11 -8
  138. package/lib/module/features/browser-config.js.map +1 -1
  139. package/lib/module/features/browser.js +2 -2
  140. package/lib/module/features/favorites.js +13 -10
  141. package/lib/module/features/favorites.js.map +1 -1
  142. package/lib/module/features/playback/controls.js +4 -2
  143. package/lib/module/features/playback/controls.js.map +1 -1
  144. package/lib/module/features/playback/state.js +2 -3
  145. package/lib/module/features/playback/state.js.map +1 -1
  146. package/lib/module/features/player/options.js.map +1 -1
  147. package/lib/module/features/player/setup.js +3 -1
  148. package/lib/module/features/player/setup.js.map +1 -1
  149. package/lib/module/features/player/validateOptions.js +2 -0
  150. package/lib/module/features/player/validateOptions.js.map +1 -1
  151. package/lib/module/features/queue/activeTrack.js +14 -3
  152. package/lib/module/features/queue/activeTrack.js.map +1 -1
  153. package/lib/module/features/queue/queue.js +11 -4
  154. package/lib/module/features/queue/queue.js.map +1 -1
  155. package/lib/module/utils/LazyNativeEmitter.js +21 -10
  156. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  157. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  158. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  159. package/lib/module/utils/getTrackIdentity.js +20 -0
  160. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  161. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  162. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  163. package/lib/module/web/NativeAudioBrowser.js +32 -24
  164. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  165. package/lib/module/web/browser/BrowserManager.js +144 -87
  166. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  167. package/lib/module/web/browser/FavoriteManager.js +31 -21
  168. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  169. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  170. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  171. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  172. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  173. package/lib/module/web/browser/sections.js +83 -0
  174. package/lib/module/web/browser/sections.js.map +1 -0
  175. package/lib/module/web/player/NowPlayingManager.js +4 -3
  176. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  177. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  178. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  179. package/lib/typescript/src/AudioBrowser.d.ts +1 -0
  180. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  181. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  182. package/lib/typescript/src/features/browser.d.ts +2 -2
  183. package/lib/typescript/src/features/favorites.d.ts +13 -10
  184. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  185. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  186. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  187. package/lib/typescript/src/features/playback/state.d.ts +2 -3
  188. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  189. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  190. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  191. package/lib/typescript/src/features/player/options.d.ts +21 -19
  192. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  193. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  194. package/lib/typescript/src/features/player/validateOptions.d.ts +1 -8
  195. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -1
  196. package/lib/typescript/src/features/queue/activeTrack.d.ts +8 -1
  197. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  198. package/lib/typescript/src/features/queue/queue.d.ts +5 -2
  199. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  200. package/lib/typescript/src/types/browser-native.d.ts +2 -2
  201. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  202. package/lib/typescript/src/types/browser-nodes.d.ts +260 -127
  203. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  204. package/lib/typescript/src/types/browser.d.ts +33 -53
  205. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  206. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +16 -17
  207. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  208. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  209. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  210. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  211. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  212. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  213. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  214. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +0 -1
  215. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  216. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -3
  217. package/nitrogen/generated/android/c++/JBrowseResult.hpp +10 -4
  218. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  219. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -3
  220. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -3
  221. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -3
  222. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -3
  223. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -3
  224. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -3
  225. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -2
  226. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -2
  227. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +10 -4
  228. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -3
  229. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +2 -5
  230. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -3
  231. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +9 -3
  232. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -2
  233. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +21 -14
  234. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  235. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -3
  236. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +16 -10
  237. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +8 -2
  238. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +6 -7
  239. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +2 -5
  240. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  241. package/nitrogen/generated/android/c++/JOptions.hpp +2 -5
  242. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -3
  243. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  244. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +49 -51
  245. package/nitrogen/generated/android/c++/JSection.hpp +114 -0
  246. package/nitrogen/generated/android/c++/JSectionStyle.hpp +69 -0
  247. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  248. package/nitrogen/generated/android/c++/JTrack.hpp +19 -48
  249. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -2
  250. package/nitrogen/generated/android/c++/JTrackStyle.hpp +25 -18
  251. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +10 -4
  252. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  253. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  254. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  255. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +5 -5
  256. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  257. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  258. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -18
  259. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +50 -0
  260. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → SectionStyle.kt} +12 -6
  261. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  262. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -15
  263. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +23 -5
  264. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -82
  265. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +16 -10
  266. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +16 -10
  267. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  268. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  269. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  270. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +5 -5
  271. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  272. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  273. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +54 -83
  274. package/nitrogen/generated/ios/swift/Section.swift +100 -0
  275. package/nitrogen/generated/ios/swift/SectionStyle.swift +64 -0
  276. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  277. package/nitrogen/generated/ios/swift/Track.swift +20 -73
  278. package/nitrogen/generated/ios/swift/TrackStyle.swift +28 -22
  279. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  280. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  281. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  282. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -6
  283. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  284. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  285. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +30 -38
  286. package/nitrogen/generated/shared/c++/Section.hpp +106 -0
  287. package/nitrogen/generated/shared/c++/SectionStyle.hpp +96 -0
  288. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  289. package/nitrogen/generated/shared/c++/Track.hpp +15 -31
  290. package/nitrogen/generated/shared/c++/TrackStyle.hpp +50 -34
  291. package/package.json +3 -2
  292. package/src/AudioBrowser.ts +1 -0
  293. package/src/features/browser-config.test.ts +21 -6
  294. package/src/features/browser-config.ts +11 -8
  295. package/src/features/browser.test.ts +1 -1
  296. package/src/features/browser.ts +2 -2
  297. package/src/features/favorites.ts +13 -10
  298. package/src/features/hook-wiring.test.ts +63 -15
  299. package/src/features/playback/controls.ts +4 -2
  300. package/src/features/playback/state.ts +2 -3
  301. package/src/features/playback/trackPlaybackTime.ts +1 -1
  302. package/src/features/player/options.ts +27 -19
  303. package/src/features/player/setup.test.ts +12 -3
  304. package/src/features/player/setup.ts +3 -1
  305. package/src/features/player/validateOptions.ts +2 -0
  306. package/src/features/queue/activeTrack.ts +14 -3
  307. package/src/features/queue/queue.ts +11 -4
  308. package/src/nitro-stub-drift.test.ts +277 -0
  309. package/src/public-surface.test.ts +40 -0
  310. package/src/specs/audio-browser.nitro.ts +4 -3
  311. package/src/style-resolution.test.ts +139 -0
  312. package/src/types/browser-native.ts +2 -2
  313. package/src/types/browser-nodes.ts +268 -136
  314. package/src/types/browser.ts +34 -55
  315. package/src/utils/LazyNativeEmitter.ts +20 -10
  316. package/src/utils/NativeUpdatedValue.ts +29 -13
  317. package/src/utils/getTrackIdentity.ts +20 -0
  318. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  319. package/src/web/NativeAudioBrowser.ts +37 -22
  320. package/src/web/browser/BrowserManager.ts +176 -95
  321. package/src/web/browser/FavoriteManager.test.ts +84 -0
  322. package/src/web/browser/FavoriteManager.ts +28 -23
  323. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  324. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  325. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  326. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  327. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  328. package/src/web/browser/sections.test.ts +91 -0
  329. package/src/web/browser/sections.ts +72 -0
  330. package/src/web/player/NowPlayingManager.ts +4 -3
  331. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  332. package/src/web/util/BrowserPathHelper.ts +61 -13
  333. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  334. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  335. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  336. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  337. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  338. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  339. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  340. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  341. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  342. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  343. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  344. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  345. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
@@ -89,21 +89,24 @@ public final class RNABCarPlayController: NSObject {
89
89
  audioBrowser?.browserManager.config ?? BrowserConfig()
90
90
  }
91
91
 
92
+ /// The configured list playing-indicator location, in CarPlay-framework terms.
93
+ private var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
94
+ switch audioBrowser?.carPlayPlayingIndicatorLocation {
95
+ case .trailing: .trailing
96
+ case .leading, nil: .leading
97
+ }
98
+ }
99
+
92
100
  /// Whether a list item identifies the currently active (loaded) track.
93
101
  ///
94
- /// Identity is the stable `Track.id` when both sides carry one — the active
102
+ /// Compares track identities (`id` when non-blank, else `src`): the active
95
103
  /// track may have been loaded outside the browse tree (the consumer's own
96
104
  /// `load`/`setQueue`) with a `src` that differs textually from the browse
97
105
  /// 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
106
+ /// item. A nil identity on either side never matches.
107
+ private func isActiveTrack(identity: String?) -> Bool {
108
+ guard let identity, let currentTrack = audioBrowser?.getPlayer()?.currentTrack else { return false }
109
+ return currentTrack.identity == identity
107
110
  }
108
111
 
109
112
  // MARK: - Initialization
@@ -118,8 +121,8 @@ public final class RNABCarPlayController: NSObject {
118
121
  self?.listItemFactory?.createListItem(for: track, handler: handler)
119
122
  ?? CPListItem(text: track.title, detailText: nil)
120
123
  }
121
- nowPlayingManager.navigateToUrl = { [weak self] url, title in
122
- Task { @MainActor in await self?.navigateToUrl(url, title: title) }
124
+ nowPlayingManager.navigateToPath = { [weak self] path, title in
125
+ Task { @MainActor in await self?.navigateToPath(path, title: title) }
123
126
  }
124
127
  }
125
128
 
@@ -178,12 +181,13 @@ public final class RNABCarPlayController: NSObject {
178
181
 
179
182
  // Create list item factory
180
183
  let factory = CarPlayListItemFactory(
181
- isActiveTrack: { [weak self] id, src in self?.isActiveTrack(id: id, src: src) ?? false },
184
+ isActiveTrack: { [weak self] identity in self?.isActiveTrack(identity: identity) ?? false },
182
185
  onItemSelected: { [weak self] track, completion in
183
186
  self?.handleItemSelection(track: track, completion: completion)
184
187
  },
185
188
  )
186
189
  factory.imageLoader = self.imageLoader
190
+ factory.playingIndicatorLocation = self.playingIndicatorLocation
187
191
  self.listItemFactory = factory
188
192
 
189
193
  self.setupContentSubscriptions()
@@ -261,7 +265,7 @@ public final class RNABCarPlayController: NSObject {
261
265
  }
262
266
 
263
267
  // Subscribe to config changes (for Now Playing buttons and per-track
264
- // button state — e.g. resolveAlbumUrl appearing/disappearing)
268
+ // button state — e.g. resolveAlbumPath appearing/disappearing)
265
269
  audioBrowser.browserManager.onConfigChanged = { [weak self] _ in
266
270
  Task { @MainActor in
267
271
  self?.nowPlayingManager.setupNowPlayingButtons()
@@ -269,13 +273,22 @@ public final class RNABCarPlayController: NSObject {
269
273
  }
270
274
  }
271
275
 
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).
276
+ // Now-playing buttons + Up Next + the list playing-indicator location are
277
+ // runtime-updatable via updateOptions(); refresh them when player options
278
+ // change (configureBrowser is no longer the only path that can change them).
275
279
  let optionsToken = audioBrowser.playerOptionsChangedEmitter.addListener { [weak self] in
276
280
  Task { @MainActor in
277
- self?.nowPlayingManager.setupNowPlayingButtons()
278
- self?.nowPlayingManager.updateNowPlayingButtonStates()
281
+ guard let self else { return }
282
+ self.nowPlayingManager.setupNowPlayingButtons()
283
+ self.nowPlayingManager.updateNowPlayingButtonStates()
284
+ // The factory's copy doubles as the last-applied value: sweep the
285
+ // templates only when the location actually changed, not on every
286
+ // updateOptions() call.
287
+ let location = self.playingIndicatorLocation
288
+ if let factory = self.listItemFactory, factory.playingIndicatorLocation != location {
289
+ factory.playingIndicatorLocation = location
290
+ self.updatePlayingIndicators()
291
+ }
279
292
  }
280
293
  }
281
294
  listenerRemovals.append { [weak audioBrowser] in
@@ -409,9 +422,7 @@ public final class RNABCarPlayController: NSObject {
409
422
  private func buildInitialInterface() async {
410
423
  guard let audioBrowser else {
411
424
  logger.error("AudioBrowser not available")
412
- await showRootNavigationError(
413
- NavigationError(code: .unknownError, message: "", statusCode: nil, statusCodeSuccess: nil),
414
- )
425
+ await showRootNavigationError(.code(.unknownError))
415
426
  return
416
427
  }
417
428
 
@@ -430,16 +441,10 @@ public final class RNABCarPlayController: NSObject {
430
441
  showLoadingTemplate()
431
442
  }
432
443
  do {
444
+ // An empty query result routes to the .emptyContent error page inside
445
+ // showTabBar, shared with runtime tab changes.
433
446
  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
- }
447
+ await showTabBar(tabs: queriedTabs)
443
448
  } catch {
444
449
  logger.error("Failed to query tabs: \(error.localizedDescription)")
445
450
  await showRootNavigationError(NavigationError.from(error))
@@ -459,7 +464,13 @@ public final class RNABCarPlayController: NSObject {
459
464
  // MARK: - Tab Bar
460
465
 
461
466
  @MainActor
462
- private func showTabBar(tabs: [Track]) async {
467
+ private func showTabBar(tabs allTabs: [Track]) async {
468
+ // A disabled tab hides — CPTabBarTemplate has no gray affordance, so per
469
+ // Track.disabled's rendering ladder an unavailable tab must not render as
470
+ // a normal-looking control (matches Android Auto, which filters before
471
+ // its 4-tab cap).
472
+ let tabs = allTabs.filter { $0.disabled != true }
473
+
463
474
  // Serialize concurrent tab-bar builds across BOTH the gated and non-gated
464
475
  // paths with one shared generation token: bump at entry, capture, and bail
465
476
  // before any template mutation once a newer build supersedes us. Both paths
@@ -473,6 +484,17 @@ public final class RNABCarPlayController: NSObject {
473
484
  gateBuildGeneration &+= 1
474
485
  let generation = gateBuildGeneration
475
486
 
487
+ // A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
488
+ // to the .emptyContent error page like every other failure (formatted
489
+ // through the app's formatNavigationError, ADR 0001). The guard lives at
490
+ // this choke point — both the initial build and runtime tab changes pass
491
+ // through here — and sits before the gate check, since a gated build from
492
+ // zero tabs is equally blank.
493
+ guard !tabs.isEmpty else {
494
+ await showRootNavigationError(.code(.emptyContent))
495
+ return
496
+ }
497
+
476
498
  // While gated, each tab path is resolved per-request: a gated tab renders
477
499
  // the gate page with that request's chrome; an allowed tab shows real
478
500
  // content. This generalizes the old "all tabs show the gate" — a path the
@@ -501,8 +523,8 @@ public final class RNABCarPlayController: NSObject {
501
523
  interfaceController.safeSetRoot(tabBar, animated: true)
502
524
 
503
525
  // 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)
526
+ if let firstTemplate = tabTemplates.first, let firstTab = tabs.first, let path = firstTab.path {
527
+ await loadContent(for: path, into: firstTemplate)
506
528
  }
507
529
  }
508
530
 
@@ -540,7 +562,7 @@ public final class RNABCarPlayController: NSObject {
540
562
  var templates: [CPListTemplate] = []
541
563
  for tab in limitedTabs {
542
564
  let outcome = await audioBrowser.gateDecision(
543
- for: NativeGateRequest(reason: .browse, path: tab.url, search: nil),
565
+ for: NativeGateRequest(reason: .browse, path: tab.path, search: nil),
544
566
  )
545
567
  guard gateBuildGeneration == generation else { return } // superseded by a newer build
546
568
  if outcome.gated {
@@ -566,8 +588,8 @@ public final class RNABCarPlayController: NSObject {
566
588
  // Eagerly fill any allowed (content) tabs: templateDidAppear isn't
567
589
  // guaranteed to re-fire for templates swapped in via updateTemplates.
568
590
  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)
591
+ guard getPath(from: template) != nil, let path = tab.path else { continue }
592
+ await loadContent(for: path, into: template)
571
593
  }
572
594
  }
573
595
 
@@ -576,7 +598,7 @@ public final class RNABCarPlayController: NSObject {
576
598
  /// until its content lazy-loads on first appearance.
577
599
  private func createTabTemplate(for track: Track) -> CPListTemplate {
578
600
  // The path stored on the template drives lazy loading and refresh.
579
- let template = makeLoadingTemplate(title: track.title, path: track.url)
601
+ let template = makeLoadingTemplate(title: track.title, path: track.path)
580
602
  applyTabBarEntry(to: template, for: track)
581
603
  return template
582
604
  }
@@ -682,14 +704,13 @@ public final class RNABCarPlayController: NSObject {
682
704
  // even with no rows — the "Ask Siri to Play" cell IS its content — so it
683
705
  // renders rather than falling through to the empty-content state.
684
706
  let hasAssistantCell = resolved.carPlaySiriListButton != nil
685
- if resolved.children?.isEmpty ?? true, !hasAssistantCell {
707
+ // A page of empty sections is still empty.
708
+ let hasChildren = resolved.normalizedSections?.contains { !$0.children.isEmpty } ?? false
709
+ if !hasChildren, !hasAssistantCell {
686
710
  // Empty is modeled as a navigation error (code .emptyContent) so it goes
687
711
  // through the same path-aware formatter as failures — letting an app give
688
712
  // 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)
713
+ await showNavigationErrorView(.code(.emptyContent), path: path, on: template)
693
714
  } else {
694
715
  updateTemplate(template, with: resolved)
695
716
  }
@@ -749,7 +770,7 @@ public final class RNABCarPlayController: NSObject {
749
770
  }
750
771
 
751
772
  // If this track is already loaded, resume playback and show Now Playing.
752
- if track.src != nil, isActiveTrack(id: track.id, src: track.src) {
773
+ if track.src != nil, isActiveTrack(identity: track.identity) {
753
774
  try? audioBrowser.play()
754
775
  nowPlayingManager.showNowPlaying()
755
776
  completion()
@@ -777,7 +798,7 @@ public final class RNABCarPlayController: NSObject {
777
798
 
778
799
  // Release CarPlay immediately so the list never locks up. Apple's handler
779
800
  // 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
801
+ // by pushing the destination and filling it in (see navigateToPath), and for
781
802
  // playback the Now Playing surface owns its own loading state.
782
803
  completion()
783
804
 
@@ -790,8 +811,8 @@ public final class RNABCarPlayController: NSObject {
790
811
  self.nowPlayingManager.showNowPlaying(popToFront: false)
791
812
  case .intercepted:
792
813
  self.nowPlayingManager.showNowPlaying(popToFront: false)
793
- case let .browse(url):
794
- await self.navigateToUrl(url, title: track.title)
814
+ case let .browse(path):
815
+ await self.navigateToPath(path, title: track.title)
795
816
  case .none:
796
817
  break
797
818
  }
@@ -809,24 +830,24 @@ public final class RNABCarPlayController: NSObject {
809
830
  }
810
831
  }
811
832
 
812
- /// Pushes a browsable URL's destination immediately as an empty, spinning list
833
+ /// Pushes a browsable path's destination immediately as an empty, spinning list
813
834
  /// template — so the list the user tapped from is never blocked. The content is
814
835
  /// filled by `templateDidAppear` → `loadContentIfNeeded` → `loadContent`, which
815
836
  /// runs once the template is on screen (the timing CarPlay needs: updates made
816
837
  /// right after a push are dropped). Backing out and re-tapping retries.
817
838
  @MainActor
818
- private func navigateToUrl(_ url: String, title: String) async {
839
+ private func navigateToPath(_ path: String, title: String) async {
819
840
  // The gate is resolved per-request: a gated path pushes the gate page
820
841
  // (with this request's chrome) instead of browsing into content; this also
821
842
  // blocks indirect entries like the Now Playing album line. An allowed path
822
843
  // browses normally even while a gate is active.
823
844
  if isGated, let audioBrowser {
824
845
  let outcome = await audioBrowser.gateDecision(
825
- for: NativeGateRequest(reason: .browse, path: url, search: nil),
846
+ for: NativeGateRequest(reason: .browse, path: path, search: nil),
826
847
  )
827
848
  if outcome.gated {
828
849
  audioBrowser.onGate(GateEvent(reason: .browse))
829
- if let top = interfaceController.topTemplate, getPath(from: top) == url { return }
850
+ if let top = interfaceController.topTemplate, getPath(from: top) == path { return }
830
851
  interfaceController.safePush(
831
852
  makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
832
853
  )
@@ -834,24 +855,24 @@ public final class RNABCarPlayController: NSObject {
834
855
  }
835
856
  }
836
857
  // Avoid pushing a duplicate if the top template already shows this path.
837
- if let top = interfaceController.topTemplate, getPath(from: top) == url {
858
+ if let top = interfaceController.topTemplate, getPath(from: top) == path {
838
859
  return
839
860
  }
840
861
  // …and guard a rapid double-tap whose first push hasn't appeared yet (so the
841
862
  // check above can't see it). Cleared when the pushed template appears.
842
- guard !navigatingPaths.contains(url) else { return }
843
- navigatingPaths.insert(url)
863
+ guard !navigatingPaths.contains(path) else { return }
864
+ navigatingPaths.insert(path)
844
865
 
845
- let template = makeLoadingTemplate(title: title, path: url)
866
+ let template = makeLoadingTemplate(title: title, path: path)
846
867
  interfaceController.safePush(template, animated: true) { [weak self] pushed, error in
847
868
  guard !pushed else { return }
848
869
  // The push can fail (e.g. CarPlay's template stack depth limit). The
849
870
  // appear callback that normally clears the guard will never fire, so
850
871
  // clear it here — otherwise the destination stays unreachable until
851
872
  // CarPlay reconnects.
852
- self?.navigatingPaths.remove(url)
873
+ self?.navigatingPaths.remove(path)
853
874
  if let error {
854
- self?.logger.error("pushTemplate failed for \(url): \(error.localizedDescription)")
875
+ self?.logger.error("pushTemplate failed for \(path): \(error.localizedDescription)")
855
876
  }
856
877
  }
857
878
  }
@@ -930,14 +951,14 @@ public final class RNABCarPlayController: NSObject {
930
951
  @MainActor
931
952
  private func handleTabsChanged(_ tabs: [Track]) {
932
953
  logger.debug("Tabs changed: \(tabs.count) tabs")
933
- // Same tabs by URL while ungated → only the tab-bar entries changed
954
+ // Same tabs by path while ungated → only the tab-bar entries changed
934
955
  // (e.g. titles after a locale switch): restamp them in place. No rebuild,
935
956
  // so the selected tab and any pushed navigation stack survive. (Gate
936
957
  // templates carry no path, so a gated tab bar never matches here.)
937
958
  if !isGated,
938
959
  let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
939
960
  tabBar.templates.count == min(tabs.count, CPTabBarTemplate.maximumTabCount),
940
- zip(tabBar.templates, tabs).allSatisfy({ getPath(from: $0) == $1.url })
961
+ zip(tabBar.templates, tabs).allSatisfy({ getPath(from: $0) == $1.path })
941
962
  {
942
963
  for (template, tab) in zip(tabBar.templates, tabs) {
943
964
  applyTabBarEntry(to: template, for: tab)
@@ -978,7 +999,7 @@ public final class RNABCarPlayController: NSObject {
978
999
  // This callback fires when the main browser's content changes.
979
1000
  // For CarPlay-specific refreshes (e.g., favorites), use notifyContentChanged instead.
980
1001
  guard let content else { return }
981
- refreshTemplatesForPath(content.url, with: content)
1002
+ refreshTemplatesForPath(content.path, with: content)
982
1003
  }
983
1004
 
984
1005
  @MainActor
@@ -990,9 +1011,11 @@ public final class RNABCarPlayController: NSObject {
990
1011
  nowPlayingManager.handleActiveTrackChanged()
991
1012
  }
992
1013
 
993
- /// Updates the isPlaying state on all list items based on the current active track.
1014
+ /// Updates the isPlaying state (and indicator location, which options can
1015
+ /// change at runtime) on all list items based on the current active track.
994
1016
  @MainActor
995
1017
  fileprivate func updatePlayingIndicators() {
1018
+ let location = playingIndicatorLocation
996
1019
  var templates: [CPListTemplate] = []
997
1020
 
998
1021
  if let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate {
@@ -1015,16 +1038,20 @@ public final class RNABCarPlayController: NSObject {
1015
1038
  for item in section.items {
1016
1039
  guard let listItem = item as? CPListItem,
1017
1040
  let info = listItem.carPlayItemInfo,
1018
- info.src != nil
1041
+ info.identity != nil
1019
1042
  else { continue }
1020
1043
 
1021
- let isPlaying = isActiveTrack(id: info.id, src: info.src)
1044
+ let isPlaying = isActiveTrack(identity: info.identity)
1022
1045
  if listItem.isPlaying != isPlaying {
1023
1046
  logger.debug(
1024
- "Updating isPlaying for \(info.id ?? info.src ?? ""): \(listItem.isPlaying) → \(isPlaying)")
1047
+ "Updating isPlaying for \(info.identity ?? ""): \(listItem.isPlaying) → \(isPlaying)")
1025
1048
  listItem.isPlaying = isPlaying
1026
1049
  templateChanged = true
1027
1050
  }
1051
+ if listItem.playingIndicatorLocation != location {
1052
+ listItem.playingIndicatorLocation = location
1053
+ templateChanged = true
1054
+ }
1028
1055
  }
1029
1056
  }
1030
1057
  // Setting isPlaying on an already-displayed item does not reliably reach
@@ -1336,12 +1363,23 @@ private extension RNABCarPlayController {
1336
1363
  }
1337
1364
  }
1338
1365
 
1366
+ // MARK: - NavigationError Shorthand
1367
+
1368
+ extension NavigationError {
1369
+ /// A code-only error: the user-facing copy comes from the app's
1370
+ /// formatNavigationError (ADR 0001), so no message travels with it.
1371
+ static func code(_ code: NavigationErrorType) -> NavigationError {
1372
+ NavigationError(code: code, message: "", statusCode: nil, statusCodeSuccess: nil)
1373
+ }
1374
+ }
1375
+
1339
1376
  // MARK: - UIImage Resize
1340
1377
 
1341
1378
  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)
1379
+ /// Draws the image centered within the target size, maintaining aspect
1380
+ /// ratio, at the given display scale (the car screen's, not the phone's).
1381
+ func resized(to targetSize: CGSize, scale: CGFloat) -> UIImage? {
1382
+ UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
1345
1383
  defer { UIGraphicsEndImageContext() }
1346
1384
 
1347
1385
  // Scale to fit while maintaining aspect ratio
@@ -36,30 +36,39 @@ final class CarPlayImageLoader {
36
36
  return sfSymbolImageForSize(symbolName, canvasSize: canvasSize, backgroundColor: bg, symbolColor: fg)
37
37
  }
38
38
 
39
- /// Cached empty placeholder image to reserve space while artwork loads
40
- lazy var placeholderImage: UIImage? = {
41
- let size = CPListItem.maximumImageSize
42
- let scale = carTraitCollection.displayScale
43
- UIGraphicsBeginImageContextWithOptions(size, false, scale)
39
+ /// Cached empty placeholders, keyed by point size — list items and image-row
40
+ /// cells reserve different footprints.
41
+ private var placeholders: [String: UIImage] = [:]
42
+
43
+ /// Empty placeholder image reserving a `size`-point slot while artwork loads.
44
+ func placeholderImage(size: CGSize) -> UIImage? {
45
+ let key = "\(size.width)×\(size.height)"
46
+ if let cached = placeholders[key] { return cached }
47
+ UIGraphicsBeginImageContextWithOptions(size, false, carTraitCollection.displayScale)
44
48
  defer { UIGraphicsEndImageContext() }
45
- return UIGraphicsGetImageFromCurrentImageContext()
46
- }()
49
+ guard let image = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
50
+ placeholders[key] = image
51
+ return image
52
+ }
47
53
 
48
54
  // MARK: - Artwork Loading
49
55
 
50
56
  /// Loads artwork for a track with size context, using the artwork transform if configured.
51
57
  /// - Parameters:
52
58
  /// - track: The track to load artwork for
59
+ /// - style: The track's resolved style block (inherited values folded in).
60
+ /// nil = the track's own declaration — callers without a section context
61
+ /// (tabs, now-playing) have no inheritance to resolve.
53
62
  /// - size: The target size in points (will be multiplied by CarPlay display scale)
54
63
  /// - completion: Called with the loaded image, or nil on failure
55
- func loadArtwork(for track: Track, size: CGSize, completion: @escaping @Sendable (UIImage?) -> Void) {
64
+ func loadArtwork(for track: Track, style: TrackStyle? = nil, size: CGSize, completion: @escaping @Sendable (UIImage?) -> Void) {
56
65
  // A track that ships one image per appearance is loaded as two ordinary
57
66
  // single-URL loads and combined below. Recursing rather than branching
58
67
  // inside the loader means each variant still goes through the whole
59
68
  // pipeline — route config, headers, SVG rasterising, the resolve transform
60
69
  // — instead of a second, thinner fetch path that would drift from it.
61
70
  if let variants = track.artwork?.variants {
62
- loadVariants(for: track, variants: variants, size: size, completion: completion)
71
+ loadVariants(for: track, style: style, variants: variants, size: size, completion: completion)
63
72
  return
64
73
  }
65
74
 
@@ -77,7 +86,7 @@ final class CarPlayImageLoader {
77
86
  let action = await CarPlayArtworkResolver.resolve(
78
87
  artwork: track.artwork?.url,
79
88
  artworkSourceUri: track.artworkSource?.uri,
80
- artworkCarPlayTinted: track.artworkCarPlayTinted,
89
+ stencil: (style ?? track.style)?.artworkRendering == .stencil,
81
90
  targetWidth: Double(size.width),
82
91
  targetHeight: Double(size.height),
83
92
  displayScale: Double(self.carTraitCollection.displayScale),
@@ -88,7 +97,7 @@ final class CarPlayImageLoader {
88
97
  case let .sfSymbol(artwork, width, height):
89
98
  completion(self.sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height)))
90
99
  case let .fetch(uri, headers, shouldTint, isSvg):
91
- let image = await self.fetchImage(uri: uri, headers: headers, isSvg: isSvg)
100
+ let image = await self.fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
92
101
  if let image, shouldTint {
93
102
  completion(self.createAdaptiveImage(image, carTraitCollection: self.carTraitCollection))
94
103
  } else {
@@ -115,13 +124,18 @@ final class CarPlayImageLoader {
115
124
  /// beats an empty slot, and this is usually a transient fetch failure.
116
125
  private func loadVariants(
117
126
  for track: Track,
127
+ style: TrackStyle?,
118
128
  variants: ArtworkVariants,
119
129
  size: CGSize,
120
130
  completion: @escaping @Sendable (UIImage?) -> Void,
121
131
  ) {
122
132
  Task {
123
- async let light = self.loadArtworkAsync(for: track.copying(artwork: .some(.first(variants.light))), size: size)
124
- async let dark = self.loadArtworkAsync(for: track.copying(artwork: .some(.first(variants.dark))), size: size)
133
+ async let light = self.loadArtworkAsync(
134
+ for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size,
135
+ )
136
+ async let dark = self.loadArtworkAsync(
137
+ for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size,
138
+ )
125
139
  let (lightImage, darkImage) = await (light, dark)
126
140
 
127
141
  guard let lightImage, let darkImage else {
@@ -137,19 +151,25 @@ final class CarPlayImageLoader {
137
151
  }
138
152
 
139
153
  /// `loadArtwork` as an awaitable, so the two variants can load concurrently.
140
- private func loadArtworkAsync(for track: Track, size: CGSize) async -> UIImage? {
154
+ private func loadArtworkAsync(for track: Track, style: TrackStyle?, size: CGSize) async -> UIImage? {
141
155
  await withCheckedContinuation { continuation in
142
- self.loadArtwork(for: track, size: size) { image in
156
+ self.loadArtwork(for: track, style: style, size: size) { image in
143
157
  continuation.resume(returning: image)
144
158
  }
145
159
  }
146
160
  }
147
161
 
148
162
  /// Fetches an image from a URL with optional headers and SVG processing.
149
- private func fetchImage(uri: String, headers: [String: String]?, isSvg: Bool) async -> UIImage? {
163
+ /// Rasters are downsampled to `size` points at the car's display scale —
164
+ /// CPListItem.h: "size them to the display scale of the car screen". SVGs
165
+ /// already rasterize at car scale via SVGProcessor.
166
+ private func fetchImage(uri: String, headers: [String: String]?, isSvg: Bool, size: CGSize) async -> UIImage? {
150
167
  let source = ImageSource(uri: uri, method: nil, headers: headers, body: nil)
151
- let svgScale: CGFloat? = isSvg ? carTraitCollection.displayScale : nil
152
- return await ArtworkImageFetcher.fetchImage(from: source, svgScale: svgScale)
168
+ let scale = carTraitCollection.displayScale
169
+ if isSvg {
170
+ return await ArtworkImageFetcher.fetchImage(from: source, svgScale: scale)
171
+ }
172
+ return await ArtworkImageFetcher.fetchImage(from: source, downsampleTo: (size, scale))
153
173
  }
154
174
 
155
175
  /// Creates an SF Symbol image rendered at the given canvas size.