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
@@ -1,8 +1,10 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
3
  import android.util.LruCache
4
+ import androidx.media3.common.C
4
5
  import androidx.media3.common.MediaItem
5
6
  import androidx.media3.session.MediaSession
7
+ import com.audiobrowser.extension.identity
6
8
  import com.audiobrowser.http.HttpClient
7
9
  import com.audiobrowser.http.RequestConfigBuilder
8
10
  import com.audiobrowser.util.BrowserPathHelper
@@ -10,7 +12,6 @@ import com.audiobrowser.util.TrackFactory
10
12
  import com.audiobrowser.util.artworkOf
11
13
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
12
14
  import com.margelo.nitro.audiobrowser.BrowserSourceCallbackParam
13
- import com.margelo.nitro.audiobrowser.FavoritesMatchMode
14
15
  import com.margelo.nitro.audiobrowser.Func_std__shared_ptr_Promise_std__shared_ptr_Promise_TransformableRequestConfig____
15
16
  import com.margelo.nitro.audiobrowser.ImageContext
16
17
  import com.margelo.nitro.audiobrowser.MediaReference
@@ -19,6 +20,8 @@ import com.margelo.nitro.audiobrowser.NativeRouteEntry
19
20
  import com.margelo.nitro.audiobrowser.RequestConfig
20
21
  import com.margelo.nitro.audiobrowser.ResolvedTrack
21
22
  import com.margelo.nitro.audiobrowser.SearchParams
23
+ import com.margelo.nitro.audiobrowser.Section
24
+ import com.margelo.nitro.audiobrowser.StyleDisplay
22
25
  import com.margelo.nitro.audiobrowser.Track
23
26
  import com.margelo.nitro.audiobrowser.TransformableRequestConfig
24
27
  import kotlinx.coroutines.Dispatchers
@@ -82,7 +85,7 @@ class BrowserManager {
82
85
  }
83
86
  }
84
87
 
85
- // LRU cache for individual tracks - keyed by both url and src for O(1) lookup
88
+ // LRU cache for individual tracks - keyed by both path and src for O(1) lookup
86
89
  private val trackCache = LruCache<String, Track>(3000)
87
90
 
88
91
  // LRU cache for resolved content - keyed by path
@@ -94,12 +97,12 @@ class BrowserManager {
94
97
  // concurrent reader never sees one query matched with another query's results.
95
98
  @Volatile private var lastSearch: Pair<String, Array<Track>>? = null
96
99
 
97
- // Set of favorited track identifiers (src)
100
+ // Set of favorited track identities (id when non-blank, else src — see Track.identity)
98
101
  private var favoriteIds = setOf<String>()
99
102
 
100
- // Favorite match mode, propagated from the player's `favorite` capability.
101
- // null = favoriting disabled (no row hearts). Set via setFavoriteMatch.
102
- private var favoriteMatch: FavoritesMatchMode? = null
103
+ // Whether favoriting is enabled, propagated from the player's `favorite` capability.
104
+ // false = favoriting disabled (no row hearts). Set via setFavoriteEnabled.
105
+ private var favoriteEnabled: Boolean = false
103
106
 
104
107
  // Navigation tracking to prevent race conditions
105
108
  @Volatile private var currentNavigationId = 0
@@ -150,8 +153,8 @@ class BrowserManager {
150
153
  val artworkResolutions = ArtworkResolutionRegistry()
151
154
 
152
155
  /**
153
- * Sets the favorited track identifiers. Tracks will have their favorited field hydrated based on
154
- * this list during browsing.
156
+ * Sets the favorited track identities (id when non-blank, else src). Tracks will have their
157
+ * favorited field hydrated based on this list during browsing.
155
158
  */
156
159
  fun setFavorites(favorites: List<String>) {
157
160
  favoriteIds = favorites.toSet()
@@ -159,97 +162,125 @@ class BrowserManager {
159
162
  }
160
163
 
161
164
  /**
162
- * Sets the favorite match mode (propagated from the `favorite` capability). null disables
165
+ * Enables or disables favoriting (propagated from the `favorite` capability). false disables
163
166
  * row-heart hydration.
164
167
  */
165
- fun setFavoriteMatch(match: FavoritesMatchMode?) {
166
- favoriteMatch = match
168
+ fun setFavoriteEnabled(enabled: Boolean) {
169
+ favoriteEnabled = enabled
167
170
  }
168
171
 
169
172
  /**
170
- * Updates the favorite state for a single track identifier. Called when the heart button is
171
- * tapped in media controllers.
173
+ * Updates the favorite state for a single track identity (id when non-blank, else src). Called
174
+ * when the heart button is tapped in media controllers.
172
175
  */
173
- fun updateFavorite(id: String, favorited: Boolean) {
176
+ fun updateFavorite(identity: String, favorited: Boolean) {
174
177
  favoriteIds =
175
178
  if (favorited) {
176
- favoriteIds + id
179
+ favoriteIds + identity
177
180
  } else {
178
- favoriteIds - id
181
+ favoriteIds - identity
179
182
  }
180
- Timber.d("Updated favorite for '$id' to $favorited (total: ${favoriteIds.size})")
183
+ Timber.d("Updated favorite for '$identity' to $favorited (total: ${favoriteIds.size})")
181
184
  }
182
185
 
183
186
  /**
184
187
  * Hydrates the favorited field on a track based on the favoriteIds set. No-op unless favoriting
185
- * is enabled (the `favorite` capability). Only playable (src-bearing) tracks are favoritable; the
186
- * flag is set to true OR false so non-favorited tracks still show an (empty) heart. Doesn't
187
- * overwrite API-provided values.
188
+ * is enabled (the `favorite` capability). Only tracks with an identity (id or src) are
189
+ * favoritable; the flag is set to true OR false so non-favorited tracks still show an (empty)
190
+ * heart. Doesn't overwrite API-provided values.
188
191
  */
189
192
  private fun hydrateFavorite(track: Track): Track {
190
- val match = favoriteMatch ?: return track
193
+ if (!favoriteEnabled) return track
191
194
  // Don't overwrite API-provided favorites
192
195
  if (track.favorited != null) return track
193
- // Only playable tracks are favoritable
194
- val src = track.src ?: return track
196
+ // Only identity-bearing tracks are favoritable
197
+ if (track.identity == null) return track
195
198
 
196
- val isFavorited = isFavorite(src, match)
197
-
198
- return track.copy(favorited = isFavorited)
199
+ return track.copy(favorited = isFavorite(track))
199
200
  }
200
201
 
201
- /** Whether [src] is favorited under the given match mode. */
202
- private fun isFavorite(src: String, match: FavoritesMatchMode): Boolean =
203
- when (match) {
204
- FavoritesMatchMode.EXACT -> favoriteIds.contains(src)
205
- FavoritesMatchMode.PARTIAL -> favoriteIds.any { BrowserPathHelper.containsSegment(src, it) }
206
- }
202
+ /** Whether [track]'s identity is in the favorites set. */
203
+ private fun isFavorite(track: Track): Boolean =
204
+ track.identity?.let { favoriteIds.contains(it) } == true
207
205
 
208
- /** Hydrates favorites on all children of a ResolvedTrack. */
206
+ /**
207
+ * Hydrates favorites on all tracks of a resolved page. Pages reach hydration normalized (ADR
208
+ * 0010), so `sections` is the only structure to walk.
209
+ */
209
210
  private fun hydrateChildren(resolvedTrack: ResolvedTrack): ResolvedTrack {
210
- val children = resolvedTrack.children ?: return resolvedTrack
211
- val hydratedChildren = children.map { hydrateFavorite(it) }.toTypedArray()
212
- return resolvedTrack.copy(children = hydratedChildren)
211
+ val sections = resolvedTrack.sections ?: return resolvedTrack
212
+ return resolvedTrack.copy(
213
+ sections =
214
+ sections
215
+ .map { section ->
216
+ section.copy(children = section.children.map { hydrateFavorite(it) }.toTypedArray())
217
+ }
218
+ .toTypedArray()
219
+ )
213
220
  }
214
221
 
215
- /** Cache a track by id, url, and src for O(1) lookup from any mediaId form. */
222
+ /** Cache a track by id, path, and src for O(1) lookup from any mediaId form. */
216
223
  private fun cacheTrack(track: Track) {
217
224
  track.id?.takeUnless { it.isBlank() }?.let { trackCache.put(it, track) }
218
- track.url?.let { trackCache.put(it, track) }
225
+ track.path?.let { trackCache.put(it, track) }
219
226
  track.src?.let { trackCache.put(it, track) }
220
227
  }
221
228
 
222
229
  private fun cacheChildren(resolvedTrack: ResolvedTrack) {
223
- resolvedTrack.children?.forEach { track ->
224
- cacheTrack(track)
225
- // Image-row items are playable surfaces of their own: cache them (with
226
- // their stamped contextual urls) so an id-keyed mediaId from an
227
- // expanded Android Auto tile can find its way back to queue expansion.
228
- track.imageRow?.forEach { item ->
229
- cacheTrack(with(TrackFactory) { item.toTrack(groupTitle = track.title) })
230
- }
230
+ resolvedTrack.flattenedChildren?.forEach { cacheTrack(it) }
231
+ }
232
+
233
+ // Pages already warned about by warnIfGridPageLacksPromise — the mismatch
234
+ // re-observes on every onGetChildren serve, and once is a diagnostic while
235
+ // every-scroll is noise. Concurrent: onGetChildren serves run on the IO
236
+ // pool, several in flight at once.
237
+ private val promiseWarnedPaths = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
238
+
239
+ /**
240
+ * Dev diagnostic (ADR 0011): a page serving grid sections under a browsable handle that declared
241
+ * no `style.display` promise still renders its parent-level layout as a list on Android Auto —
242
+ * the parent hint is emitted only when declared, never derived from resolved pages and never
243
+ * back-filled. Warns at the one site where the mismatch is observable; a cache miss for the
244
+ * handle (cold start straight into the page) just skips the check.
245
+ *
246
+ * Only an all-grid page warns: the parent-level hint lays out the WHOLE page, so on a mixed page
247
+ * (a teaser shelf above list sections) there is no correct promise to advise — grid would tile
248
+ * the list sections too.
249
+ */
250
+ fun warnIfGridPageLacksPromise(path: String, sections: List<Section>) {
251
+ if (sections.isEmpty() || !sections.all { it.style?.display == StyleDisplay.GRID }) return
252
+ val handle = trackCache.get(path) ?: return
253
+ if (handle.src != null) return // rendered playable — opens no page, promises nothing
254
+ if (handle.style?.display == null && promiseWarnedPaths.add(path)) {
255
+ Timber.w(
256
+ "Page '%s' serves grid sections, but its browsable handle declares no style.display — " +
257
+ "Android Auto's parent-level hint was not emitted, so this page renders as a list " +
258
+ "there. Declare `style: { display: 'grid' }` on the handle track.",
259
+ path,
260
+ )
231
261
  }
232
262
  }
233
263
 
234
264
  /**
235
- * Get a cached Track by mediaId (stable id, url, or src), or null if not cached. Used by Media3
265
+ * Get a cached Track by mediaId (stable id, path, or src), or null if not cached. Used by Media3
236
266
  * to rehydrate MediaItem shells with full track metadata. Re-hydrates favorites in case
237
267
  * setFavoriteStates was called after caching.
238
268
  */
239
269
  fun getCachedTrack(mediaId: String): Track? {
240
- // Try direct lookup first (matches url or src)
270
+ // Try direct lookup first (matches path or src)
241
271
  trackCache.get(mediaId)?.let { track ->
242
272
  val hydratedTrack = hydrateFavorite(track)
243
273
  Timber.d("Cache HIT for mediaId='$mediaId' → '${track.title}'")
244
274
  return hydratedTrack
245
275
  }
246
276
 
247
- // Try extracting src from contextual URL
277
+ // Try extracting the track identity (id when non-blank, else src) from a contextual path —
278
+ // the cache is keyed by id AND src, so either identity form resolves.
248
279
  val trackId = BrowserPathHelper.extractTrackId(mediaId)
249
280
  if (trackId != null) {
250
281
  trackCache.get(trackId)?.let { track ->
251
282
  val hydratedTrack = hydrateFavorite(track)
252
- Timber.d("Cache HIT (extracted src) for mediaId='$mediaId' → '${track.title}'")
283
+ Timber.d("Cache HIT (extracted identity) for mediaId='$mediaId' → '${track.title}'")
253
284
  return hydratedTrack
254
285
  }
255
286
  }
@@ -259,19 +290,19 @@ class BrowserManager {
259
290
  }
260
291
 
261
292
  /**
262
- * Resolves a tapped mediaId to the contextual url to expand a queue from, or null when there is
293
+ * Resolves a tapped mediaId to the contextual path to expand a queue from, or null when there is
263
294
  * none. A contextual mediaId is its own; a stable-id mediaId (see TrackFactory.buildMediaItem)
264
- * resolves through the track cache to the contextual url of the container it was most recently
295
+ * resolves through the track cache to the contextual path of the container it was most recently
265
296
  * browsed in — a legacy car controller round-trips only the mediaId, so the cache is what
266
297
  * remembers which list the row came from.
267
298
  */
268
- fun contextualUrlFor(mediaId: String): String? {
299
+ fun contextualPathFor(mediaId: String): String? {
269
300
  if (BrowserPathHelper.isContextual(mediaId)) return mediaId
270
- return getCachedTrack(mediaId)?.url?.takeIf { BrowserPathHelper.isContextual(it) }
301
+ return getCachedTrack(mediaId)?.path?.takeIf { BrowserPathHelper.isContextual(it) }
271
302
  }
272
303
 
273
304
  /**
274
- * Resolves a single Media3 MediaItem to a Track. Prefers the track cache (keyed by id, url, and
305
+ * Resolves a single Media3 MediaItem to a Track. Prefers the track cache (keyed by id, path, and
275
306
  * src).
276
307
  *
277
308
  * A cache miss is legitimately reachable: a controller can replay a mediaId this process never
@@ -304,26 +335,23 @@ class BrowserManager {
304
335
  // A mediaId distinct from the playable uri is the track's stable id —
305
336
  // keep it so the item's identity (car now-playing row match) survives.
306
337
  id = mediaId.takeIf { it != fallbackSrc },
307
- url = null,
338
+ path = null,
308
339
  src = fallbackSrc,
309
340
  artwork = artworkOf(metadata.artworkUri?.toString()),
310
341
  artworkSource = null,
311
342
  request = null,
312
- artworkCarPlayTinted = null,
313
343
  title = metadata.title?.toString() ?: mediaId,
314
344
  subtitle = metadata.artist?.toString(),
315
345
  artist = null,
316
- albumUrl = null,
346
+ albumPath = null,
317
347
  album = metadata.albumTitle?.toString(),
318
348
  description = metadata.description?.toString(),
319
349
  genre = metadata.genre?.toString(),
320
350
  duration = null,
321
351
  style = null,
322
- childrenStyle = null,
352
+ disabled = null,
323
353
  favorited = null,
324
- groupTitle = null,
325
354
  live = null,
326
- imageRow = null,
327
355
  )
328
356
  )
329
357
  }
@@ -361,17 +389,19 @@ class BrowserManager {
361
389
  if (searchQuery != null) {
362
390
  Timber.d("Handling search playback request for query: $searchQuery")
363
391
 
364
- // Execute search (will hit cache if already performed)
392
+ // Execute search (will hit cache if already performed). Disabled
393
+ // tracks never play, so they never enter a search-built queue.
365
394
  val searchResults = search(searchQuery)
366
- val searchTracks = searchResults.children
395
+ val searchTracks =
396
+ searchResults.flattenedChildren?.filterNot { it.disabled == true }?.toTypedArray()
367
397
 
368
398
  if (searchTracks != null && searchTracks.isNotEmpty()) {
369
399
  // Find the selected track in search results (mediaId is the stable id
370
- // when the track has one, else url/src — see TrackFactory)
400
+ // when the track has one, else path/src — see TrackFactory)
371
401
  val mediaId = mediaItem.mediaId
372
402
  val selectedIndex =
373
403
  searchTracks.indexOfFirst { track ->
374
- track.id == mediaId || track.url == mediaId || track.src == mediaId
404
+ track.id == mediaId || track.path == mediaId || track.src == mediaId
375
405
  }
376
406
 
377
407
  if (selectedIndex >= 0) {
@@ -398,12 +428,12 @@ class BrowserManager {
398
428
  val mediaId = mediaItems[0].mediaId
399
429
  // A failed search match falls through to plain resolution, never to browse
400
430
  // expansion — the user asked for that one result, not a browsed list.
401
- val contextualUrl = if (searchQuery == null) contextualUrlFor(mediaId) else null
431
+ val contextualPath = if (searchQuery == null) contextualPathFor(mediaId) else null
402
432
 
403
- if (contextualUrl != null) {
404
- Timber.d("Attempting queue expansion for mediaId='$mediaId' via '$contextualUrl'")
433
+ if (contextualPath != null) {
434
+ Timber.d("Attempting queue expansion for mediaId='$mediaId' via '$contextualPath'")
405
435
 
406
- val expanded = expandQueueFromContextualUrl(contextualUrl)
436
+ val expanded = expandQueueFromContextualPath(contextualPath)
407
437
 
408
438
  if (expanded != null) {
409
439
  val (tracks, selectedIndex) = expanded
@@ -423,20 +453,56 @@ class BrowserManager {
423
453
  // No expansion - resolve from cache (with a minimal-track fallback for replayed mediaIds)
424
454
  val resolvedTracks = mediaItems.map { resolveMediaItemToTrack(it) }
425
455
 
456
+ // A disabled track is unavailable — it never plays, even by a stale or
457
+ // replayed mediaId (Track.disabled). Refusal fails the future: Media3
458
+ // surfaces an error to the controller and the current playback stays
459
+ // untouched (an empty item list would clear the live timeline instead).
460
+ //
461
+ // Media3's legacy path — how Android Auto and Assistant actually request
462
+ // playback (onPlayFromMediaId et al.) — passes C.INDEX_UNSET, meaning
463
+ // "the default item", i.e. the first. Normalize before the guard, or the
464
+ // refusal is dead on exactly the path that serves cars.
465
+ val effectiveStartIndex = if (startIndex == C.INDEX_UNSET) 0 else startIndex
466
+ val startTrack = resolvedTracks.getOrNull(effectiveStartIndex)
467
+ if (startTrack != null && startTrack.disabled == true) {
468
+ Timber.w("Refusing playback of disabled track: ${startTrack.title}")
469
+ throw IllegalStateException("Refusing playback of disabled track: ${startTrack.title}")
470
+ }
471
+ val playableTracks = resolvedTracks.filterNot { it.disabled == true }
472
+ if (playableTracks.isEmpty()) {
473
+ // Belt over the start-item guard: never hand Media3 an empty timeline.
474
+ Timber.w("Refusing playback: every requested track is disabled")
475
+ throw IllegalStateException("Refusing playback: every requested track is disabled")
476
+ }
477
+ // Positional, not indexOf: value equality would alias a duplicated track
478
+ // (the same station twice in a queue) to its first copy. An unset index
479
+ // stays unset — Media3's "default item" semantics pass through as they
480
+ // did before the filter existed.
481
+ val adjustedStartIndex =
482
+ when {
483
+ startIndex == C.INDEX_UNSET -> C.INDEX_UNSET
484
+ startTrack == null -> 0
485
+ else -> resolvedTracks.take(effectiveStartIndex).count { it.disabled != true }
486
+ }
487
+
426
488
  // Convert to Media3 MediaItems
427
- val resolvedMediaItems = resolvedTracks.map { track -> TrackFactory.toMedia3(track) }
489
+ val resolvedMediaItems = playableTracks.map { track -> TrackFactory.toMedia3(track) }
428
490
 
429
- return MediaSession.MediaItemsWithStartPosition(resolvedMediaItems, startIndex, startPositionMs)
491
+ return MediaSession.MediaItemsWithStartPosition(
492
+ resolvedMediaItems,
493
+ adjustedStartIndex,
494
+ startPositionMs,
495
+ )
430
496
  }
431
497
 
432
498
  /**
433
- * Validates that a track has either url or src for stable identification. Throws
499
+ * Validates that a track has either path or src for stable identification. Throws
434
500
  * IllegalStateException if validation fails.
435
501
  */
436
502
  private fun validateTrack(track: Track, context: String) {
437
- if (track.url == null && track.src == null) {
503
+ if (track.path == null && track.src == null) {
438
504
  throw IllegalStateException(
439
- "$context must have either 'url' or 'src' property for stable identification. Track: ${track.title}"
505
+ "$context must have either 'path' or 'src' property for stable identification. Track: ${track.title}"
440
506
  )
441
507
  }
442
508
  }
@@ -444,12 +510,12 @@ class BrowserManager {
444
510
  suspend fun resolve(path: String, useCache: Boolean = true): ResolvedTrack {
445
511
  Timber.d("=== RESOLVE: path='$path' (useCache=$useCache) ===")
446
512
 
447
- // Strip __trackId from contextual URLs (e.g., "/library/radio?__trackId=song.mp3" →
513
+ // Strip __trackId from contextual paths (e.g., "/library/radio?__trackId=song.mp3" →
448
514
  // "/library/radio")
449
- // This allows resolving the parent container for tracks referenced by contextual URL
515
+ // This allows resolving the parent container for tracks referenced by contextual path
450
516
  val normalizedPath = BrowserPathHelper.stripTrackId(path)
451
517
  if (normalizedPath != path) {
452
- Timber.d("Stripped __trackId from contextual URL: '$normalizedPath'")
518
+ Timber.d("Stripped __trackId from contextual path: '$normalizedPath'")
453
519
  }
454
520
 
455
521
  // Check content cache first
@@ -457,7 +523,7 @@ class BrowserManager {
457
523
  contentCache.get(normalizedPath)?.let { cached ->
458
524
  Timber.d("Content cache HIT for path='$normalizedPath'")
459
525
  // Re-key the track cache even on a hit: an id-keyed lookup (stable-id
460
- // mediaId → contextual url, see contextualUrlFor) must reflect the
526
+ // mediaId → contextual path, see contextualPathFor) must reflect the
461
527
  // most recently *browsed* container, which a cached re-display
462
528
  // otherwise wouldn't re-register.
463
529
  cacheChildren(cached)
@@ -483,11 +549,11 @@ class BrowserManager {
483
549
  * (e.g., via notifyContentChanged).
484
550
  *
485
551
  * @param path The container path to invalidate (e.g., "/library/radio")
486
- * @throws IllegalArgumentException if passed a contextual URL (contains __trackId)
552
+ * @throws IllegalArgumentException if passed a contextual path (contains __trackId)
487
553
  */
488
554
  fun invalidateContentCache(path: String) {
489
555
  require(!BrowserPathHelper.isContextual(path)) {
490
- "invalidateContentCache() expects a container path, not a contextual URL: $path"
556
+ "invalidateContentCache() expects a container path, not a contextual path: $path"
491
557
  }
492
558
  contentCache.remove(path)
493
559
  Timber.d("Invalidated content cache for path='$path'")
@@ -524,81 +590,67 @@ class BrowserManager {
524
590
  effectiveArtworkConfig = config.artwork
525
591
  }
526
592
 
527
- // Transform children: generate contextual URLs and transform artwork URLs
528
- val transformedChildren =
529
- resolvedTrack.children?.let { children ->
530
- coroutineScope {
531
- children
532
- .mapIndexed { index, track ->
593
+ // Normalize to the canonical sectioned shape (children sugar → one untitled
594
+ // section — ADR 0010), then generate contextual paths and transform artwork
595
+ // URLs. The output never carries `children`.
596
+ val sections = resolvedTrack.normalizedSections ?: return resolvedTrack
597
+ // The contextual index is the flat page position — children concatenated in
598
+ // section order (ADR 0009/0010).
599
+ var flatIndex = 0
600
+ // One scope for the whole page: every child's transform (which can suspend
601
+ // on artwork resolution) launches up front across all sections, then the
602
+ // sections reassemble — a slow section doesn't serialize the ones after it.
603
+ val transformedSections = coroutineScope {
604
+ sections
605
+ .map { section ->
606
+ val base = flatIndex
607
+ flatIndex += section.children.size
608
+ section to
609
+ section.children.mapIndexed { offset, track ->
533
610
  async {
534
- // Validate that track has stable identifier. A track carrying an
535
- // imageRow is exempt: a url-less row is a pure preview — never
536
- // selected, navigated to, or cached (its items carry their own
537
- // identity; on Android Auto it expands into them).
538
- if (track.imageRow.isNullOrEmpty()) validateTrack(track, "Child track")
611
+ val index = base + offset
612
+ // Validate that track has stable identifier.
613
+ validateTrack(track, "Child track")
539
614
 
540
615
  var transformedTrack = track
541
616
 
542
- // Generate contextual URLs for playable tracks
617
+ // Generate contextual paths for playable tracks
543
618
  // Always regenerate to reflect the current browsing context, not the original
544
619
  // context
545
620
  // (e.g., a track favorited from an album should use /favorites context when browsed
546
621
  // there)
547
- if (track.src != null) {
548
- val contextualUrl = BrowserPathHelper.build(path, track.src)
549
- transformedTrack = transformedTrack.copy(url = contextualUrl)
622
+ // The flat page position rides along as a duplicate-identity tie-breaker.
623
+ val trackIdentity = track.identity
624
+ if (track.src != null && trackIdentity != null) {
625
+ val contextualPath = BrowserPathHelper.build(path, trackIdentity, index)
626
+ transformedTrack = transformedTrack.copy(path = contextualPath)
550
627
 
551
628
  Timber.d(
552
- "[$path] Child[$index] '${track.title}': Playable, contextualUrl=$contextualUrl (src=${track.src})"
629
+ "[$path] Child[$index] '${track.title}': Playable, contextualPath=$contextualPath (identity=$trackIdentity)"
553
630
  )
554
631
  } else {
555
632
  Timber.d(
556
- "[$path] Child[$index] '${track.title}': Browsable with url=${track.url}"
633
+ "[$path] Child[$index] '${track.title}': Browsable with path=${track.path}"
557
634
  )
558
635
  }
559
636
 
560
- // Playable image-row items get contextual URLs like any list
561
- // row, so a tile tap expands into its section's queue instead
562
- // of a queue of one (ADR 0006).
563
- transformedTrack.imageRow?.let { items ->
564
- transformedTrack =
565
- transformedTrack.copy(
566
- imageRow =
567
- items
568
- .map { item ->
569
- if (item.url == null && item.src != null) {
570
- item.copy(url = BrowserPathHelper.build(path, item.src))
571
- } else {
572
- item
573
- }
574
- }
575
- .toTypedArray()
576
- )
577
- }
578
-
579
637
  // Transform artwork URL. At browse-time there is no display size info.
580
- transformedTrack =
581
- transformArtworkUrl(
582
- transformedTrack,
583
- effectiveArtworkConfig,
584
- path,
585
- index,
586
- ImageContext(null, null),
587
- )
588
-
589
- transformedTrack
638
+ transformArtworkUrl(
639
+ transformedTrack,
640
+ effectiveArtworkConfig,
641
+ path,
642
+ index,
643
+ ImageContext(null, null),
644
+ )
590
645
  }
591
646
  }
592
- .awaitAll()
593
647
  }
594
- }
595
-
596
- // Return resolved track with transformed children
597
- return if (transformedChildren != null) {
598
- resolvedTrack.copy(children = transformedChildren.toTypedArray())
599
- } else {
600
- resolvedTrack
648
+ .map { (section, transformedChildren) ->
649
+ section.copy(children = transformedChildren.awaitAll().toTypedArray())
650
+ }
601
651
  }
652
+
653
+ return resolvedTrack.copy(sections = transformedSections.toTypedArray(), children = null)
602
654
  }
603
655
 
604
656
  /**
@@ -657,55 +709,68 @@ class BrowserManager {
657
709
  }
658
710
 
659
711
  /**
660
- * Expands a contextual URL into a queue of playable tracks.
712
+ * Expands a contextual path into a queue of playable tracks.
661
713
  *
662
714
  * Used when navigating to a track to load it with its full album/playlist context. Returns only
663
715
  * the selected track if singleTrack is true.
664
716
  *
665
- * @param contextualUrl The contextual URL (e.g., "/album?__trackId=song.mp3")
717
+ * @param contextualPath The contextual path (e.g., "/album?__trackId=song.mp3")
666
718
  * @return Pair of (tracks array, selected track index), or null if expansion fails
667
719
  */
668
- suspend fun expandQueueFromContextualUrl(contextualUrl: String): Pair<Array<Track>, Int>? {
669
- val trackId = BrowserPathHelper.extractTrackId(contextualUrl) ?: return null
720
+ suspend fun expandQueueFromContextualPath(contextualPath: String): Pair<Array<Track>, Int>? {
721
+ val trackId = BrowserPathHelper.extractTrackId(contextualPath) ?: return null
670
722
 
671
- Timber.d("Expanding queue from contextual URL: $contextualUrl (trackId=$trackId)")
723
+ Timber.d("Expanding queue from contextual path: $contextualPath (trackId=$trackId)")
672
724
 
673
725
  try {
674
726
  // Resolve the parent container to get all siblings
675
- val parentPath = BrowserPathHelper.stripTrackId(contextualUrl)
727
+ val parentPath = BrowserPathHelper.stripTrackId(contextualPath)
676
728
  val parentResolvedTrack = resolve(parentPath)
677
- val children = parentResolvedTrack.children
729
+ val sections = parentResolvedTrack.normalizedSections
678
730
 
679
- if (children.isNullOrEmpty()) {
731
+ if (sections.isNullOrEmpty()) {
680
732
  Timber.w("Parent has no children, cannot expand queue")
681
733
  return null
682
734
  }
683
735
 
684
- // Queue scope is the tapped section, not the whole page (ADR 0006). An
685
- // id that no longer appears on the page aborts the expansion — the
686
- // caller falls back to the stored single track; silently queueing the
687
- // changed list would resume the wrong station.
688
- val sectionTracks =
689
- when (val section = SectionScope.section(children.toList(), trackId)) {
690
- is SectionScope.Section.ImageRow ->
691
- with(TrackFactory) { section.items.map { it.toTrack(groupTitle = null) } }
692
- is SectionScope.Section.Run -> section.tracks
693
- null -> return null
694
- }
695
-
696
- // Filter to only playable tracks (tracks with src)
697
- val playableTracks = sectionTracks.filter { track -> track.src != null }
736
+ // Queue scope is the tapped section, not the whole page (ADR 0006). The
737
+ // stamped flat index pins which section (and which copy) was tapped when
738
+ // the same identity appears more than once; it is only a tie-breaker — a
739
+ // stale index falls back to the first identity match. An id that no
740
+ // longer appears on the page at all aborts the expansion — the caller
741
+ // falls back to the stored single track; silently queueing the changed
742
+ // list would resume the wrong station.
743
+ val tappedIndex = BrowserPathHelper.extractIndex(contextualPath)
744
+ val scoped = SectionScope.scoped(sections, trackId, tappedIndex) ?: return null
745
+ val sectionTracks = scoped.section.children.toList()
746
+ val tappedOffset = scoped.tappedOffset
747
+
748
+ // Filter to only playable tracks (tracks with src). A disabled track is
749
+ // unavailable — queue expansion excludes it, so auto-advance never meets
750
+ // one.
751
+ val playableTracks =
752
+ sectionTracks.filter { track -> track.src != null && track.disabled != true }
698
753
 
699
754
  if (playableTracks.isEmpty()) {
700
755
  Timber.w("Parent has no playable tracks, cannot expand queue")
701
756
  return null
702
757
  }
703
758
 
704
- // Find the index of the selected track in the playable tracks array
705
- val selectedIndex = playableTracks.indexOfFirst { track -> track.src == trackId }
759
+ // Find the index of the selected track in the playable tracks array:
760
+ // the pinned copy when the stamp survived, else the first identity match
761
+ val selectedIndex =
762
+ if (
763
+ tappedOffset != null &&
764
+ sectionTracks[tappedOffset].src != null &&
765
+ sectionTracks[tappedOffset].disabled != true
766
+ ) {
767
+ sectionTracks.take(tappedOffset + 1).count { it.src != null && it.disabled != true } - 1
768
+ } else {
769
+ playableTracks.indexOfFirst { track -> track.identity == trackId }
770
+ }
706
771
 
707
772
  if (selectedIndex < 0) {
708
- Timber.w("Track with src='$trackId' not found in playable children")
773
+ Timber.w("Track with identity='$trackId' not found in playable children")
709
774
  return null
710
775
  }
711
776
 
@@ -716,11 +781,11 @@ class BrowserManager {
716
781
  }
717
782
 
718
783
  Timber.d(
719
- "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${children.size} total), starting at index $selectedIndex"
784
+ "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${sectionTracks.size} in section), starting at index $selectedIndex"
720
785
  )
721
786
  return Pair(playableTracks.toTypedArray(), selectedIndex)
722
787
  } catch (e: Exception) {
723
- Timber.e(e, "Error expanding queue from contextual URL: $contextualUrl")
788
+ Timber.e(e, "Error expanding queue from contextual path: $contextualPath")
724
789
  return null
725
790
  }
726
791
  }
@@ -822,7 +887,10 @@ class BrowserManager {
822
887
  */
823
888
  suspend fun searchPlayable(params: SearchParams): Array<Track>? {
824
889
  val searchResults = search(params)
825
- val tracks = searchResults.children
890
+ // Voice search never matches a disabled track (Track.disabled) — filtered
891
+ // before the first result is picked, so an unavailable one can't capture
892
+ // the play.
893
+ val tracks = searchResults.flattenedChildren?.filter { it.disabled != true }?.toTypedArray()
826
894
 
827
895
  if (tracks.isNullOrEmpty()) {
828
896
  return null
@@ -832,13 +900,13 @@ class BrowserManager {
832
900
 
833
901
  // Check if result is browsable-only (container/route) vs playable
834
902
  // If it's browsable but also playable (has src or playable=true), treat it as playable
835
- val firstResultUrl = firstResult.url
903
+ val firstResultPath = firstResult.path
836
904
  val tracksToFilter =
837
- if (firstResult.src == null && firstResultUrl != null) {
838
- Timber.d("First search result is browsable-only, resolving: $firstResultUrl")
839
- val resolvedTrack = resolve(firstResultUrl)
840
- resolvedTrack.children
841
- ?.filter { it.src != null }
905
+ if (firstResult.src == null && firstResultPath != null) {
906
+ Timber.d("First search result is browsable-only, resolving: $firstResultPath")
907
+ val resolvedTrack = resolve(firstResultPath)
908
+ resolvedTrack.flattenedChildren
909
+ ?.filter { it.src != null && it.disabled != true }
842
910
  ?.takeIf { it.isNotEmpty() }
843
911
  ?.toTypedArray() ?: tracks
844
912
  } else {
@@ -883,34 +951,35 @@ class BrowserManager {
883
951
  val searchPath = BrowserPathHelper.createSearchPath(params.query)
884
952
 
885
953
  try {
886
- // Execute search. Drop results without a stable identifier (url or src): search results come
954
+ // Execute search. Drop results without a stable identifier (path or src): search results come
887
955
  // from server/JS data that doesn't pass through validateTrack like browse children do, and
888
956
  // downstream conversion (TrackFactory.toMedia3) and browsable-result resolution require one.
889
957
  val searchResults =
890
958
  resolveSearch(params)
891
959
  .filter { track ->
892
- val valid = track.url != null || track.src != null
960
+ val valid = track.path != null || track.src != null
893
961
  if (!valid) {
894
- Timber.w("Dropping search result without url or src: '${track.title}'")
962
+ Timber.w("Dropping search result without path or src: '${track.title}'")
895
963
  }
896
964
  valid
897
965
  }
898
966
  .toTypedArray()
899
967
 
900
- // Create ResolvedTrack
968
+ // Search results are a flat list; as a resolved page they are one
969
+ // untitled section (ADR 0010).
901
970
  val searchResolvedTrack =
902
971
  ResolvedTrack(
903
972
  id = null,
904
- url = searchPath,
973
+ path = searchPath,
905
974
  title = "Search: ${params.query}",
906
- children = searchResults,
975
+ sections = arrayOf(untitledSection(searchResults)),
976
+ children = null,
907
977
  carPlaySiriListButton = null,
908
978
  artwork = null,
909
979
  artworkSource = null,
910
980
  request = null,
911
- artworkCarPlayTinted = null,
912
981
  artist = null,
913
- albumUrl = null,
982
+ albumPath = null,
914
983
  description = null,
915
984
  subtitle = null,
916
985
  album = null,
@@ -918,11 +987,9 @@ class BrowserManager {
918
987
  duration = null,
919
988
  src = null,
920
989
  style = null,
921
- childrenStyle = null,
990
+ disabled = null,
922
991
  favorited = null,
923
- groupTitle = null,
924
992
  live = null,
925
- imageRow = null,
926
993
  )
927
994
 
928
995
  // Cache search results for getCachedSearchResults()
@@ -942,16 +1009,16 @@ class BrowserManager {
942
1009
  val emptySearchResult =
943
1010
  ResolvedTrack(
944
1011
  id = null,
945
- url = searchPath,
1012
+ path = searchPath,
946
1013
  title = "Search: ${params.query}",
947
- children = emptyArray(),
1014
+ sections = arrayOf(untitledSection(emptyArray())),
1015
+ children = null,
948
1016
  carPlaySiriListButton = null,
949
1017
  artwork = null,
950
1018
  artworkSource = null,
951
1019
  request = null,
952
- artworkCarPlayTinted = null,
953
1020
  artist = null,
954
- albumUrl = null,
1021
+ albumPath = null,
955
1022
  description = null,
956
1023
  subtitle = null,
957
1024
  album = null,
@@ -959,11 +1026,9 @@ class BrowserManager {
959
1026
  duration = null,
960
1027
  src = null,
961
1028
  style = null,
962
- childrenStyle = null,
1029
+ disabled = null,
963
1030
  favorited = null,
964
- groupTitle = null,
965
1031
  live = null,
966
- imageRow = null,
967
1032
  )
968
1033
 
969
1034
  return emptySearchResult
@@ -1040,7 +1105,7 @@ class BrowserManager {
1040
1105
  // Validate tabs have stable identifiers
1041
1106
  tabs.forEachIndexed { index, tab ->
1042
1107
  validateTrack(tab, "Tab")
1043
- Timber.d("[TABS] Tab[$index] '${tab.title}': url=${tab.url}")
1108
+ Timber.d("[TABS] Tab[$index] '${tab.title}': path=${tab.path}")
1044
1109
  }
1045
1110
 
1046
1111
  this.tabs = tabs
@@ -1178,7 +1243,8 @@ class BrowserManager {
1178
1243
  Timber.d("Resolving tabs via route entry")
1179
1244
  val resolvedTrack = resolveRouteEntry(tabsEntry, TABS_ROUTE_PATH, emptyMap())
1180
1245
 
1181
- return resolvedTrack.children ?: emptyArray()
1246
+ // Tabs are a flat list, not a page — a sectioned tabs source flattens.
1247
+ return resolvedTrack.flattenedChildren?.toTypedArray() ?: emptyArray()
1182
1248
  }
1183
1249
 
1184
1250
  /**