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
@@ -48,9 +48,9 @@ struct BrowserConfig {
48
48
  let carPlayLoadingTitle: String?
49
49
 
50
50
  /// Resolves a browse path for the CarPlay Now Playing album line when the
51
- /// active track has no albumUrl. Invoked on track changes (not at tap) so
51
+ /// active track has no albumPath. Invoked on track changes (not at tap) so
52
52
  /// the album line only becomes tappable when a destination exists.
53
- let resolveAlbumUrl: ((_ track: Track) -> Promise<String?>)?
53
+ let resolveAlbumPath: ((_ track: Track) -> Promise<String?>)?
54
54
 
55
55
  /// Custom handler for track load events (overrides default load behavior)
56
56
  let handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)?
@@ -72,7 +72,7 @@ struct BrowserConfig {
72
72
  handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)? = nil,
73
73
  androidControllerOfflineError: Bool = true,
74
74
  carPlayLoadingTitle: String? = nil,
75
- resolveAlbumUrl: ((_ track: Track) -> Promise<String?>)? = nil,
75
+ resolveAlbumPath: ((_ track: Track) -> Promise<String?>)? = nil,
76
76
  formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)? = nil,
77
77
  ) {
78
78
  self.request = request
@@ -87,7 +87,7 @@ struct BrowserConfig {
87
87
  self.handleTrackLoad = handleTrackLoad
88
88
  self.androidControllerOfflineError = androidControllerOfflineError
89
89
  self.carPlayLoadingTitle = carPlayLoadingTitle
90
- self.resolveAlbumUrl = resolveAlbumUrl
90
+ self.resolveAlbumPath = resolveAlbumPath
91
91
  self.formatNavigationError = formatNavigationError
92
92
  }
93
93
 
@@ -105,10 +105,31 @@ struct BrowserConfig {
105
105
  handleTrackLoad = config.handleTrackLoad
106
106
  androidControllerOfflineError = config.androidControllerOfflineError ?? true
107
107
  carPlayLoadingTitle = config.carPlayLoadingTitle
108
- resolveAlbumUrl = config.resolveAlbumUrl
108
+ resolveAlbumPath = config.resolveAlbumPath
109
109
  formatNavigationError = config.formatNavigationError
110
110
  }
111
111
 
112
+ /// A copy of this config with every JS-provided function dropped — at every
113
+ /// level, not just the top; each nested type's strip below nils its own —
114
+ /// keeping the static fields (URLs, headers, routes' shape). Makes the config
115
+ /// safe to hold after its JS runtime is gone: callers take their
116
+ /// static/built-in fallback paths (a callback-only route degrades to its
117
+ /// no-content path). See `HybridAudioBrowser.dropStaleJSCallbacks` for why
118
+ /// invoking a stale callback is fatal.
119
+ func strippingJSCallbacks() -> BrowserConfig {
120
+ BrowserConfig(
121
+ request: request?.strippingJSCallbacks(),
122
+ browse: browse?.strippingJSCallbacks(),
123
+ media: media?.strippingJSCallbacks(),
124
+ artwork: artwork?.strippingJSCallbacks(),
125
+ nowPlayingArtwork: nowPlayingArtwork?.strippingJSCallbacks(),
126
+ routes: routes?.map { $0.strippingJSCallbacks() },
127
+ singleTrack: singleTrack,
128
+ androidControllerOfflineError: androidControllerOfflineError,
129
+ carPlayLoadingTitle: carPlayLoadingTitle,
130
+ )
131
+ }
132
+
112
133
  // MARK: - Track Load Handler
113
134
 
114
135
  private static let logger = Logger(subsystem: "com.audiobrowser", category: "BrowserConfig")
@@ -139,3 +160,79 @@ struct BrowserConfig {
139
160
  // MARK: - NativeRouteEntry + RouteEntry conformance
140
161
 
141
162
  extension NativeRouteEntry: RouteEntry {}
163
+
164
+ // MARK: - Deep JS-callback stripping
165
+
166
+ // The generated structs are get-only from Swift, so each strip rebuilds the
167
+ // value through the memberwise initializer with the function fields nil'd.
168
+
169
+ private extension TransformableRequestConfig {
170
+ func strippingJSCallbacks() -> TransformableRequestConfig {
171
+ TransformableRequestConfig(
172
+ transform: nil,
173
+ transformSync: nil,
174
+ method: method,
175
+ path: path,
176
+ baseUrl: baseUrl,
177
+ headers: headers,
178
+ query: query,
179
+ body: body,
180
+ contentType: contentType,
181
+ userAgent: userAgent,
182
+ )
183
+ }
184
+ }
185
+
186
+ private extension MediaRequestConfig {
187
+ func strippingJSCallbacks() -> MediaRequestConfig {
188
+ MediaRequestConfig(
189
+ resolve: nil,
190
+ resolveSync: nil,
191
+ transform: nil,
192
+ transformSync: nil,
193
+ method: method,
194
+ path: path,
195
+ baseUrl: baseUrl,
196
+ headers: headers,
197
+ query: query,
198
+ body: body,
199
+ contentType: contentType,
200
+ userAgent: userAgent,
201
+ )
202
+ }
203
+ }
204
+
205
+ private extension ArtworkRequestConfig {
206
+ func strippingJSCallbacks() -> ArtworkRequestConfig {
207
+ ArtworkRequestConfig(
208
+ resolve: nil,
209
+ resolveSync: nil,
210
+ transform: nil,
211
+ transformSync: nil,
212
+ imageQueryParams: imageQueryParams,
213
+ method: method,
214
+ path: path,
215
+ baseUrl: baseUrl,
216
+ headers: headers,
217
+ query: query,
218
+ body: body,
219
+ contentType: contentType,
220
+ userAgent: userAgent,
221
+ )
222
+ }
223
+ }
224
+
225
+ private extension NativeRouteEntry {
226
+ func strippingJSCallbacks() -> NativeRouteEntry {
227
+ NativeRouteEntry(
228
+ path: path,
229
+ browseCallback: nil,
230
+ browseConfig: browseConfig?.strippingJSCallbacks(),
231
+ browseStatic: browseStatic,
232
+ searchCallback: nil,
233
+ searchConfig: searchConfig?.strippingJSCallbacks(),
234
+ media: media?.strippingJSCallbacks(),
235
+ artwork: artwork?.strippingJSCallbacks(),
236
+ )
237
+ }
238
+ }
@@ -262,10 +262,14 @@ extension BrowserManager {
262
262
  }
263
263
 
264
264
  // Substitute the `{id}` template token with the track's id across path/query/header values.
265
- // (Configs without the token are unaffected — e.g. browse artwork.) Only when the track has an id.
266
- if let id = track.id, !id.isEmpty {
267
- mergedConfig = substituteTrackId(in: mergedConfig, id: id)
268
- }
265
+ // (Configs without the token are unaffected — e.g. browse artwork.) A track without a
266
+ // non-blank id leaves the token LITERALLY in place — the request then visibly 404s, which
267
+ // is intended (self-describing error trail) — with a single warning logged below once the
268
+ // URL is built.
269
+ mergedConfig = substituteTrackId(in: mergedConfig, id: track.id)
270
+ let hasUnfilledIdToken = TrackIdTemplate.containsToken(mergedConfig.path)
271
+ || TrackIdTemplate.containsToken(mergedConfig.query)
272
+ || TrackIdTemplate.containsToken(mergedConfig.headers)
269
273
 
270
274
  // Build final URL - if no path after merging, no artwork to transform
271
275
  guard mergedConfig.path != nil else {
@@ -280,6 +284,10 @@ extension BrowserManager {
280
284
  headers["User-Agent"] = userAgent
281
285
  }
282
286
 
287
+ if hasUnfilledIdToken {
288
+ logger.warning("nowPlayingArtwork: track has no id — '{id}' left unfilled in \(uri)")
289
+ }
290
+
283
291
  logger.debug("Artwork URL transformed: \(track.artwork?.url ?? "nil") -> \(uri)")
284
292
 
285
293
  return ImageSource(
@@ -315,18 +323,14 @@ extension BrowserManager {
315
323
 
316
324
  /// Replaces the `{id}` token with the track id in a request config's path, query values, and
317
325
  /// header values. Used so a `nowPlayingArtwork` like `{ path: "/artwork/{id}" }` resolves.
318
- private func substituteTrackId(in config: RequestConfig, id: String) -> RequestConfig {
319
- func sub(_ s: String?) -> String? { s?.replacingOccurrences(of: "{id}", with: id) }
320
- func subDict(_ d: [String: String]?) -> [String: String]? {
321
- guard let d else { return nil }
322
- return d.mapValues { $0.replacingOccurrences(of: "{id}", with: id) }
323
- }
324
- return RequestConfig(
326
+ /// A nil/blank id leaves the values unchanged (token left literal — see TrackIdTemplate).
327
+ private func substituteTrackId(in config: RequestConfig, id: String?) -> RequestConfig {
328
+ RequestConfig(
325
329
  method: config.method,
326
- path: sub(config.path),
330
+ path: TrackIdTemplate.substitute(config.path, id: id),
327
331
  baseUrl: config.baseUrl,
328
- headers: subDict(config.headers),
329
- query: subDict(config.query),
332
+ headers: TrackIdTemplate.substitute(config.headers, id: id),
333
+ query: TrackIdTemplate.substitute(config.query, id: id),
330
334
  body: config.body,
331
335
  contentType: config.contentType,
332
336
  userAgent: config.userAgent,
@@ -77,12 +77,12 @@ final class BrowserManager {
77
77
  var resolvedRequestLayer: TransformableRequestConfig?
78
78
  private var resolvedBrowseLayer: TransformableRequestConfig?
79
79
 
80
- // Set of favorited track identifiers (src)
80
+ // Set of favorited track identities (id when non-blank, else src).
81
81
  private var favoriteIds: Set<String> = []
82
82
 
83
- // Favorite match mode, propagated from the player's `favorite` capability.
84
- // nil = favoriting disabled (no row hearts). Set via setFavoriteMatch.
85
- private var favoriteMatch: FavoritesMatchMode?
83
+ // Whether favoriting is enabled, propagated from the player's `favorite`
84
+ // capability. false = no row hearts. Set via setFavoriteEnabled.
85
+ private var favoriteEnabled = false
86
86
 
87
87
  // Navigation tracking to prevent race conditions
88
88
  private var currentNavigationId: Int = 0
@@ -162,62 +162,49 @@ final class BrowserManager {
162
162
  onFavoritesChanged?()
163
163
  }
164
164
 
165
- /// Sets the favorite match mode (propagated from the `favorite` capability).
166
- /// nil disables row-heart hydration.
167
- func setFavoriteMatch(_ match: FavoritesMatchMode?) {
168
- favoriteMatch = match
165
+ /// Enables/disables favoriting (propagated from the `favorite` capability).
166
+ /// false disables row-heart hydration.
167
+ func setFavoriteEnabled(_ enabled: Bool) {
168
+ favoriteEnabled = enabled
169
169
  }
170
170
 
171
- /// Optimistically reflects a local favorite toggle for `src` in the match set,
172
- /// needing no consumer-specific id extraction: favoriting inserts `src` (which
173
- /// matches itself under either mode). Removal mirrors `isFavorite`'s match
174
- /// semantics — partial drops every id that is a path segment of `src` (the
175
- /// channel uid, and self-heals stray ids); exact (or disabled) drops only
176
- /// `src`, so unrelated exact favorites that happen to be a segment of `src`
177
- /// aren't collaterally lost. The consumer reconciles `favoriteIds` to its
178
- /// canonical ids on the next `setFavorites`; `isFavorite(src:)` gives the same
179
- /// answer before and after, so the now-playing heart stays responsive.
180
- func setFavorited(src: String, favorited: Bool) {
171
+ /// Optimistically reflects a local favorite toggle in the favorite set, keyed
172
+ /// by the track's identity (id when non-blank, else src). The consumer
173
+ /// reconciles `favoriteIds` to its canonical set on the next `setFavorites`;
174
+ /// `isFavorite` gives the same answer before and after, so the now-playing
175
+ /// heart stays responsive.
176
+ func setFavorited(identity: String, favorited: Bool) {
181
177
  if favorited {
182
- favoriteIds.insert(src)
183
- } else if favoriteMatch == .partial {
184
- favoriteIds = favoriteIds.filter { !BrowserPathHelper.containsSegment(src, $0) }
178
+ favoriteIds.insert(identity)
185
179
  } else {
186
- favoriteIds.remove(src)
180
+ favoriteIds.remove(identity)
187
181
  }
188
182
  }
189
183
 
190
184
  /// Hydrates the favorited field on a track based on the favoriteIds set.
191
185
  /// No-op unless favoriting is enabled (the `favorite` capability). Only
192
- /// playable (src-bearing) tracks are favoritable; the flag is set to true OR
193
- /// false so non-favorited tracks still show an (empty) heart. Local
194
- /// favoriteIds take precedence over API-provided values.
186
+ /// identity-bearing tracks (id or src) are favoritable; the flag is set to
187
+ /// true OR false so non-favorited tracks still show an (empty) heart. A
188
+ /// caller-set `favorited` wins — API/consumer-provided values are never
189
+ /// overwritten (matches Android and web).
195
190
  func hydrateFavorite(_ track: Track) -> Track {
196
- guard let match = favoriteMatch, let src = track.src else { return track }
197
-
198
- let isFavorited = isFavorite(src: src, match: match)
199
-
200
- // Only create a new track if the favorited state differs
201
- if track.favorited == isFavorited { return track }
202
-
203
- return track.copying(favorited: isFavorited)
191
+ guard favoriteEnabled, track.favorited == nil, track.identity != nil else { return track }
192
+ return track.copying(favorited: isFavorite(track))
204
193
  }
205
194
 
206
- /// Whether `src` is favorited under the given match mode.
207
- private func isFavorite(src: String, match: FavoritesMatchMode) -> Bool {
208
- switch match {
209
- case .exact:
210
- favoriteIds.contains(src)
211
- case .partial:
212
- favoriteIds.contains { BrowserPathHelper.containsSegment(src, $0) }
213
- }
195
+ /// Whether the track's identity is in the favorite set.
196
+ private func isFavorite(_ track: Track) -> Bool {
197
+ track.identity.map(favoriteIds.contains) ?? false
214
198
  }
215
199
 
216
- /// Hydrates favorites on all children of a ResolvedTrack.
200
+ /// Hydrates favorites on all tracks of a resolved page. Pages reach
201
+ /// hydration normalized (ADR 0010), so `sections` is the only structure
202
+ /// to walk.
217
203
  private func hydrateChildren(_ resolvedTrack: ResolvedTrack) -> ResolvedTrack {
218
- guard let children = resolvedTrack.children else { return resolvedTrack }
219
- let hydratedChildren = children.map { hydrateFavorite($0) }
220
- return resolvedTrack.copying(children: hydratedChildren)
204
+ guard let sections = resolvedTrack.sections else { return resolvedTrack }
205
+ return resolvedTrack.copying(sections: sections.map { section in
206
+ section.copying(children: section.children.map { hydrateFavorite($0) })
207
+ })
221
208
  }
222
209
 
223
210
  // MARK: - Navigation
@@ -304,12 +291,14 @@ final class BrowserManager {
304
291
  )
305
292
  }
306
293
 
307
- // Validate and transform children
308
- if let children = resolvedTrack.children {
309
- let transformed = try await transformChildren(
310
- children, parentPath: path, routeEntry: routeEntry?.0,
294
+ // Normalize to the canonical sectioned shape (children sugar → one
295
+ // untitled section — ADR 0010), then validate and transform. The output
296
+ // never carries `children`.
297
+ if let sections = resolvedTrack.normalizedSections {
298
+ let transformed = try await transformSections(
299
+ sections, parentPath: path, routeEntry: routeEntry?.0,
311
300
  )
312
- return resolvedTrack.copying(children: transformed)
301
+ return resolvedTrack.copying(sections: transformed, children: .some(nil))
313
302
  }
314
303
 
315
304
  return resolvedTrack
@@ -426,7 +415,7 @@ final class BrowserManager {
426
415
  )
427
416
  }
428
417
 
429
- /// Resolves a browse route: a page object (`{title,url,children:[…]}`).
418
+ /// Resolves a browse route: a page object (`{title,path,children:[…]}`).
430
419
  /// The browse kind layer is the cached resolved layer (`resolvedBrowseLayer`),
431
420
  /// populated by `ensureLayersResolved()` inside `buildApiRequest`.
432
421
  private func resolveFromConfig(
@@ -447,8 +436,8 @@ final class BrowserManager {
447
436
  let nitroResult = result.toNitro()
448
437
 
449
438
  logger.debug("Resolved: \(nitroResult.title)")
450
- if let children = nitroResult.children {
451
- logger.debug(" children: \(children.count) tracks")
439
+ if let sections = nitroResult.normalizedSections {
440
+ logger.debug(" children: \(sections.reduce(0) { $0 + $1.children.count }) tracks")
452
441
  }
453
442
 
454
443
  return nitroResult
@@ -525,65 +514,51 @@ final class BrowserManager {
525
514
 
526
515
  // MARK: - Child Transformation
527
516
 
528
- private func transformChildren(
529
- _ children: [Track],
517
+ private func transformSections(
518
+ _ sections: [Section],
530
519
  parentPath: String,
531
520
  routeEntry: NativeRouteEntry?,
532
- ) async throws -> [Track] {
533
- var transformed: [Track] = []
534
-
535
- for track in children {
536
- // Validate track has stable identifier. A track carrying an imageRow is
537
- // exempt: a url-less row is a pure preview — never selected, navigated
538
- // to, or cached (its items carry their own identity).
539
- if track.url == nil, track.src == nil, track.imageRow?.isEmpty != false {
540
- throw BrowserError.invalidConfiguration(
541
- "Track must have either 'url' or 'src' for stable identification: \(track.title)",
542
- )
543
- }
544
-
545
- var transformedTrack = track
521
+ ) async throws -> [Section] {
522
+ var transformed: [Section] = []
523
+ // The contextual index is the flat page position — children concatenated
524
+ // in section order (ADR 0009/0010).
525
+ var flatIndex = 0
526
+
527
+ for section in sections {
528
+ var transformedChildren: [Track] = []
529
+
530
+ for track in section.children {
531
+ let index = flatIndex
532
+ flatIndex += 1
533
+
534
+ // Validate track has stable identifier.
535
+ if track.path == nil, track.src == nil {
536
+ throw BrowserError.invalidConfiguration(
537
+ "Track must have either 'path' or 'src' for stable identification: \(track.title)",
538
+ )
539
+ }
546
540
 
547
- if track.src != nil, track.url == nil {
548
- let contextualUrl = BrowserPathHelper.build(parentPath: parentPath, trackId: track.src!)
549
- transformedTrack = track.copying(url: contextualUrl)
550
- }
541
+ var transformedTrack = track
551
542
 
552
- // Resolve artwork URL at browse-time (no size context)
553
- let artworkConfig = routeEntry?.artwork ?? config.artwork
554
- if let imageSource = await resolveArtworkUrl(track: transformedTrack, perRouteConfig: artworkConfig) {
555
- transformedTrack = transformedTrack.copying(artworkSource: imageSource)
556
- }
543
+ if track.src != nil, track.path == nil {
544
+ // src != nil guarantees a non-nil identity (id when non-blank, else src).
545
+ // The flat page position rides along as a duplicate-identity tie-breaker.
546
+ let contextualPath = BrowserPathHelper.build(
547
+ parentPath: parentPath, trackId: track.identity!, index: index,
548
+ )
549
+ transformedTrack = track.copying(path: contextualPath)
550
+ }
557
551
 
558
- // Resolve artwork for image row items
559
- if let imageRowItems = transformedTrack.imageRow {
560
- var resolvedItems: [ImageRowItem] = []
561
- for item in imageRowItems {
562
- let itemImageSource = await resolveArtworkUrl(track: item.toTrack(), perRouteConfig: artworkConfig)
563
- // Playable items get a contextual URL like any list row, so a
564
- // thumbnail tap expands into its section's queue instead of a
565
- // queue of one (ADR 0006).
566
- let itemUrl = item.url ?? item.src.map {
567
- BrowserPathHelper.build(parentPath: parentPath, trackId: $0)
568
- }
569
- resolvedItems.append(ImageRowItem(
570
- id: item.id,
571
- url: itemUrl,
572
- src: item.src,
573
- artwork: item.artwork,
574
- artworkSource: itemImageSource,
575
- title: item.title,
576
- artist: item.artist,
577
- album: item.album,
578
- albumUrl: item.albumUrl,
579
- live: item.live,
580
- request: item.request,
581
- ))
552
+ // Resolve artwork URL at browse-time (no size context)
553
+ let artworkConfig = routeEntry?.artwork ?? config.artwork
554
+ if let imageSource = await resolveArtworkUrl(track: transformedTrack, perRouteConfig: artworkConfig) {
555
+ transformedTrack = transformedTrack.copying(artworkSource: imageSource)
582
556
  }
583
- transformedTrack = transformedTrack.copying(imageRow: resolvedItems)
557
+
558
+ transformedChildren.append(transformedTrack)
584
559
  }
585
560
 
586
- transformed.append(transformedTrack)
561
+ transformed.append(section.copying(children: transformedChildren))
587
562
  }
588
563
 
589
564
  return transformed
@@ -668,37 +643,40 @@ final class BrowserManager {
668
643
  /// station. Decision in `SearchDrillIn.playable`; mirrors Android's
669
644
  /// `searchPlayable`.
670
645
  func searchPlayable(_ params: SearchParams) async throws -> [Track]? {
671
- let children = try await (search(params).children) ?? []
672
- return try await SearchDrillIn.playable(from: children) { [self] url in
673
- logger.debug("First search result is browsable-only, resolving: \(url)")
674
- return try await (resolve(url).children) ?? []
646
+ // Voice search never matches a disabled track (Track.disabled) — filtered
647
+ // before drill-in so an unavailable first result can't capture the play.
648
+ let children = try await ((search(params).flattenedChildren) ?? [])
649
+ .filter { $0.disabled != true }
650
+ return try await SearchDrillIn.playable(from: children) { [self] path in
651
+ logger.debug("First search result is browsable-only, resolving: \(path)")
652
+ return try await ((resolve(path).flattenedChildren) ?? []).filter { $0.disabled != true }
675
653
  }
676
654
  }
677
655
 
678
656
  private func makeSearchResult(query: String, results: [Track]) -> ResolvedTrack {
657
+ // Search results are a flat list; as a resolved page they are one
658
+ // untitled section (ADR 0010).
679
659
  ResolvedTrack(
680
- url: BrowserPathHelper.createSearchPath(query),
681
- children: results,
660
+ path: BrowserPathHelper.createSearchPath(query),
661
+ style: nil,
662
+ sections: [.untitled(results)],
663
+ children: nil,
682
664
  carPlaySiriListButton: nil,
683
665
  id: nil,
684
666
  src: nil,
685
667
  artwork: nil,
686
668
  artworkSource: nil, request: nil,
687
- artworkCarPlayTinted: nil,
688
669
  title: "Search: \(query)",
689
670
  subtitle: nil,
690
671
  artist: nil,
691
- albumUrl: nil,
672
+ albumPath: nil,
692
673
  album: nil,
693
674
  description: nil,
694
675
  genre: nil,
695
676
  duration: nil,
696
- style: nil,
697
- childrenStyle: nil,
677
+ disabled: nil,
698
678
  favorited: nil,
699
- groupTitle: nil,
700
679
  live: nil,
701
- imageRow: nil,
702
680
  )
703
681
  }
704
682
 
@@ -715,7 +693,8 @@ final class BrowserManager {
715
693
  }
716
694
 
717
695
  let resolved = try await resolveRouteEntry(tabsEntry, path: Self.tabsRoutePath, params: [:])
718
- let tabTracks = resolved.children ?? []
696
+ // Tabs are a flat list, not a page — a sectioned tabs source flattens.
697
+ let tabTracks = resolved.flattenedChildren ?? []
719
698
 
720
699
  tabs = tabTracks
721
700
  return tabTracks
@@ -723,38 +702,39 @@ final class BrowserManager {
723
702
 
724
703
  // MARK: - Queue Expansion (for CarPlay/external controllers)
725
704
 
726
- /// Expands a contextual URL to a full queue of playable tracks.
727
- func expandQueueFromContextualUrl(_ url: String) async throws -> (tracks: [Track], selectedIndex: Int)? {
728
- guard BrowserPathHelper.isContextual(url) else { return nil }
705
+ /// Expands a contextual path to a full queue of playable tracks.
706
+ func expandQueueFromContextualPath(_ path: String) async throws -> (tracks: [Track], selectedIndex: Int)? {
707
+ guard BrowserPathHelper.isContextual(path) else { return nil }
729
708
 
730
- let parentPath = BrowserPathHelper.stripTrackId(url)
731
- guard let trackId = BrowserPathHelper.extractTrackId(url) else { return nil }
709
+ let parentPath = BrowserPathHelper.stripTrackId(path)
710
+ guard let trackId = BrowserPathHelper.extractTrackId(path) else { return nil }
732
711
 
733
- logger.debug("Expanding queue from contextual URL")
734
- logger.debug(" url: \(url)")
712
+ logger.debug("Expanding queue from contextual path")
713
+ logger.debug(" path: \(path)")
735
714
  logger.debug(" parentPath: \(parentPath)")
736
715
  logger.debug(" trackId: \(trackId)")
737
716
 
738
717
  // Resolve the parent container
739
718
  let resolved = try await resolve(parentPath, useCache: true)
740
- guard let children = resolved.children else { return nil }
741
-
742
- // Queue scope is the tapped section, not the whole page (ADR 0006). An
743
- // id that no longer appears on the page aborts the expansion — the
744
- // caller falls back to the stored single track (matching Android);
745
- // silently queueing the changed list would resume the wrong station.
746
- let sectionTracks: [Track]
747
- switch SectionScope.section(of: children, containing: trackId) {
748
- case let .imageRow(items):
749
- sectionTracks = items.map { $0.toTrack() }
750
- case let .run(tracks):
751
- sectionTracks = tracks
752
- case nil:
719
+ guard let sections = resolved.normalizedSections else { return nil }
720
+
721
+ // Queue scope is the tapped section, not the whole page (ADR 0006). The
722
+ // stamped flat index pins which section (and which copy) was tapped when
723
+ // the same identity appears more than once; it is only a tie-breaker —
724
+ // a stale index falls back to the first identity match. An id that no
725
+ // longer appears on the page at all aborts the expansion — the caller
726
+ // falls back to the stored single track (matching Android); silently
727
+ // queueing the changed list would resume the wrong station.
728
+ let tappedIndex = BrowserPathHelper.extractIndex(path)
729
+ guard let scoped = SectionScope.scoped(in: sections, containing: trackId, tappedIndex: tappedIndex) else {
753
730
  return nil
754
731
  }
732
+ let sectionTracks = scoped.section.children
733
+ let tappedOffset = scoped.tappedOffset
755
734
 
756
- // Filter to playable tracks (have src)
757
- let playableTracks = sectionTracks.filter { $0.src != nil }
735
+ // Filter to playable tracks (have src). A disabled track is unavailable —
736
+ // queue expansion excludes it, so auto-advance never meets one.
737
+ let playableTracks = sectionTracks.filter { $0.src != nil && $0.disabled != true }
758
738
  guard !playableTracks.isEmpty else { return nil }
759
739
 
760
740
  logger.debug("Found \(playableTracks.count) playable tracks")
@@ -762,10 +742,18 @@ final class BrowserManager {
762
742
  logger.debug(" [\(index)] \(track.title) - src: \(track.src ?? "nil")")
763
743
  }
764
744
 
765
- // Find selected track index
766
- // Unreachable in practice (the section was found BY this id), but a miss
767
- // must abort rather than silently start the queue at the wrong track.
768
- guard let selectedIndex = playableTracks.firstIndex(where: { $0.src == trackId }) else {
745
+ // Find selected track index: the pinned copy when the stamp survived,
746
+ // else the first identity match. A miss must abort rather than silently
747
+ // start the queue at the wrong track (unreachable in practice — the
748
+ // section was found BY this id).
749
+ let selectedIndex: Int
750
+ if let tappedOffset, sectionTracks[tappedOffset].src != nil, sectionTracks[tappedOffset].disabled != true {
751
+ selectedIndex =
752
+ sectionTracks.prefix(through: tappedOffset)
753
+ .count(where: { $0.src != nil && $0.disabled != true }) - 1
754
+ } else if let index = playableTracks.firstIndex(where: { $0.identity == trackId }) {
755
+ selectedIndex = index
756
+ } else {
769
757
  return nil
770
758
  }
771
759
  logger.debug("Selected track index: \(selectedIndex)")