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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (416) hide show
  1. package/README.md +14 -4
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +115 -26
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +262 -188
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +23 -3
  6. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  7. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +95 -58
  8. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +68 -26
  9. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +60 -0
  10. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +19 -7
  11. package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
  12. package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  14. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +120 -67
  15. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -24
  16. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +109 -74
  17. package/android/src/main/java/com/audiobrowser/player/Player.kt +51 -30
  18. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  19. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  20. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +44 -40
  21. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  22. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +108 -0
  23. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +52 -53
  24. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +74 -41
  25. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  26. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +229 -50
  27. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  28. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  29. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +1 -1
  30. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +147 -0
  31. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -53
  32. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +154 -0
  33. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  34. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +1 -2
  36. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  38. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  39. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  40. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +161 -0
  41. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  42. package/ios/Browser/BrowserConfig.swift +102 -5
  43. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  44. package/ios/Browser/BrowserManager.swift +229 -180
  45. package/ios/Browser/BrowserPathHelper.swift +44 -44
  46. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  47. package/ios/Browser/JsonModels.swift +258 -106
  48. package/ios/Browser/SearchDrillIn.swift +6 -6
  49. package/ios/Browser/SectionPresentation.swift +64 -0
  50. package/ios/Browser/SectionScope.swift +58 -24
  51. package/ios/Browser/StyleResolver.swift +58 -0
  52. package/ios/Browser/TabBarEntries.swift +9 -3
  53. package/ios/Browser/TrackIdTemplate.swift +37 -0
  54. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  55. package/ios/CarPlay/CarPlayController.swift +227 -85
  56. package/ios/CarPlay/CarPlayImageLoader.swift +38 -18
  57. package/ios/CarPlay/CarPlayListItemFactory.swift +289 -84
  58. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  59. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  60. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  61. package/ios/Extension/ResolvedTrack+Copying.swift +11 -15
  62. package/ios/Extension/Section+Copying.swift +31 -0
  63. package/ios/Extension/Track+Copying.swift +6 -12
  64. package/ios/Extension/Track+Identity.swift +18 -0
  65. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  66. package/ios/Http/HttpClient.swift +8 -2
  67. package/ios/HybridAudioBrowser.swift +166 -45
  68. package/ios/Model/NitroTypeStubs.swift +106 -21
  69. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  70. package/ios/Player/MediaIntentCriteria.swift +13 -5
  71. package/ios/Player/MediaLoader.swift +33 -9
  72. package/ios/Player/NowPlayingUpdater.swift +54 -6
  73. package/ios/Player/PlaybackCoordinator.swift +13 -6
  74. package/ios/Player/PlaybackTimer.swift +8 -2
  75. package/ios/Player/QueueManager.swift +36 -10
  76. package/ios/Player/RetryManager.swift +4 -3
  77. package/ios/Player/ShuffleOrder.swift +23 -0
  78. package/ios/Player/SleepTimerHandling.swift +13 -0
  79. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  80. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  81. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  82. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  83. package/ios/Tests/JsonModelsTests.swift +150 -1
  84. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  85. package/ios/Tests/QueueManagerTests.swift +72 -6
  86. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  87. package/ios/Tests/ResolvedTrackCopyingTests.swift +37 -35
  88. package/ios/Tests/SearchDrillInTests.swift +16 -16
  89. package/ios/Tests/SectionPresentationTests.swift +90 -0
  90. package/ios/Tests/SectionScopeTests.swift +87 -55
  91. package/ios/Tests/StyleResolverTests.swift +127 -0
  92. package/ios/Tests/Support/TestTypes.swift +7 -0
  93. package/ios/Tests/TabBarEntriesTests.swift +18 -18
  94. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  95. package/ios/Tests/TrackIdentityTests.swift +20 -0
  96. package/ios/Tests/TrackSelectorTests.swift +168 -33
  97. package/ios/TrackPlayer.swift +57 -34
  98. package/ios/TrackSelector.swift +45 -30
  99. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  100. package/ios/Util/NetworkMonitor.swift +43 -26
  101. package/ios/Util/SFSymbolRenderer.swift +24 -2
  102. package/ios/Util/SVGProcessor.swift +9 -2
  103. package/lib/commonjs/AudioBrowser.js +11 -0
  104. package/lib/commonjs/AudioBrowser.js.map +1 -1
  105. package/lib/commonjs/features/browser-config.js +11 -8
  106. package/lib/commonjs/features/browser-config.js.map +1 -1
  107. package/lib/commonjs/features/browser.js +2 -2
  108. package/lib/commonjs/features/favorites.js +13 -10
  109. package/lib/commonjs/features/favorites.js.map +1 -1
  110. package/lib/commonjs/features/playback/controls.js +4 -2
  111. package/lib/commonjs/features/playback/controls.js.map +1 -1
  112. package/lib/commonjs/features/playback/progress.js +2 -1
  113. package/lib/commonjs/features/playback/progress.js.map +1 -1
  114. package/lib/commonjs/features/playback/rate.js +8 -1
  115. package/lib/commonjs/features/playback/rate.js.map +1 -1
  116. package/lib/commonjs/features/playback/state.js +2 -3
  117. package/lib/commonjs/features/playback/state.js.map +1 -1
  118. package/lib/commonjs/features/player/options.js.map +1 -1
  119. package/lib/commonjs/features/player/setup.js +3 -1
  120. package/lib/commonjs/features/player/setup.js.map +1 -1
  121. package/lib/commonjs/features/player/validateOptions.js +2 -0
  122. package/lib/commonjs/features/player/validateOptions.js.map +1 -1
  123. package/lib/commonjs/features/queue/activeTrack.js +14 -3
  124. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  125. package/lib/commonjs/features/queue/queue.js +11 -4
  126. package/lib/commonjs/features/queue/queue.js.map +1 -1
  127. package/lib/commonjs/utils/LazyNativeEmitter.js +21 -10
  128. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  129. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  130. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  131. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  132. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  133. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  134. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  135. package/lib/commonjs/web/NativeAudioBrowser.js +32 -24
  136. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  137. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  138. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  139. package/lib/commonjs/web/browser/BrowserManager.js +144 -87
  140. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  141. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  142. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  143. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  144. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  145. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  146. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  147. package/lib/commonjs/web/browser/sections.js +89 -0
  148. package/lib/commonjs/web/browser/sections.js.map +1 -0
  149. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  150. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  151. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  152. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  153. package/lib/module/AudioBrowser.js +1 -0
  154. package/lib/module/AudioBrowser.js.map +1 -1
  155. package/lib/module/features/browser-config.js +11 -8
  156. package/lib/module/features/browser-config.js.map +1 -1
  157. package/lib/module/features/browser.js +2 -2
  158. package/lib/module/features/favorites.js +13 -10
  159. package/lib/module/features/favorites.js.map +1 -1
  160. package/lib/module/features/playback/controls.js +4 -2
  161. package/lib/module/features/playback/controls.js.map +1 -1
  162. package/lib/module/features/playback/progress.js +2 -1
  163. package/lib/module/features/playback/progress.js.map +1 -1
  164. package/lib/module/features/playback/rate.js +8 -1
  165. package/lib/module/features/playback/rate.js.map +1 -1
  166. package/lib/module/features/playback/state.js +2 -3
  167. package/lib/module/features/playback/state.js.map +1 -1
  168. package/lib/module/features/player/options.js.map +1 -1
  169. package/lib/module/features/player/setup.js +3 -1
  170. package/lib/module/features/player/setup.js.map +1 -1
  171. package/lib/module/features/player/validateOptions.js +2 -0
  172. package/lib/module/features/player/validateOptions.js.map +1 -1
  173. package/lib/module/features/queue/activeTrack.js +14 -3
  174. package/lib/module/features/queue/activeTrack.js.map +1 -1
  175. package/lib/module/features/queue/queue.js +11 -4
  176. package/lib/module/features/queue/queue.js.map +1 -1
  177. package/lib/module/utils/LazyNativeEmitter.js +21 -10
  178. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  179. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  180. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  181. package/lib/module/utils/getTrackIdentity.js +20 -0
  182. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  183. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  184. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  185. package/lib/module/web/NativeAudioBrowser.js +32 -24
  186. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  187. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  188. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  189. package/lib/module/web/browser/BrowserManager.js +144 -87
  190. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  191. package/lib/module/web/browser/FavoriteManager.js +31 -21
  192. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  193. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  194. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  195. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  196. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  197. package/lib/module/web/browser/sections.js +83 -0
  198. package/lib/module/web/browser/sections.js.map +1 -0
  199. package/lib/module/web/player/NowPlayingManager.js +4 -3
  200. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  201. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  202. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  203. package/lib/typescript/src/AudioBrowser.d.ts +1 -0
  204. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  205. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  206. package/lib/typescript/src/features/browser.d.ts +2 -2
  207. package/lib/typescript/src/features/favorites.d.ts +13 -10
  208. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  209. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  210. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  211. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  212. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  213. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  214. package/lib/typescript/src/features/playback/state.d.ts +2 -3
  215. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  216. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  217. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  218. package/lib/typescript/src/features/player/options.d.ts +21 -19
  219. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  220. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  221. package/lib/typescript/src/features/player/validateOptions.d.ts +1 -8
  222. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -1
  223. package/lib/typescript/src/features/queue/activeTrack.d.ts +8 -1
  224. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  225. package/lib/typescript/src/features/queue/queue.d.ts +5 -2
  226. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  227. package/lib/typescript/src/types/browser-native.d.ts +2 -2
  228. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  229. package/lib/typescript/src/types/browser-nodes.d.ts +361 -126
  230. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  231. package/lib/typescript/src/types/browser.d.ts +33 -53
  232. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  233. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +16 -17
  234. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  235. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  236. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  237. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  238. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  239. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  240. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  241. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +0 -1
  242. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  243. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +8 -3
  244. package/nitrogen/generated/android/c++/JBrowseResult.hpp +16 -4
  245. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  246. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  247. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +8 -3
  248. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +8 -3
  249. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +8 -3
  250. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +8 -3
  251. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +8 -3
  252. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +8 -3
  253. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +8 -2
  254. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +8 -2
  255. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +16 -4
  256. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +8 -3
  257. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +2 -5
  258. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +8 -3
  259. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +15 -3
  260. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +8 -2
  261. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  262. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +33 -14
  263. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  264. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  265. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +8 -3
  266. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +22 -10
  267. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +14 -2
  268. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +10 -7
  269. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +2 -5
  270. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  271. package/nitrogen/generated/android/c++/JOptions.hpp +2 -5
  272. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +8 -3
  273. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  274. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +55 -51
  275. package/nitrogen/generated/android/c++/JSection.hpp +120 -0
  276. package/nitrogen/generated/android/c++/JSectionStyle.hpp +96 -0
  277. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  278. package/nitrogen/generated/android/c++/JTrack.hpp +23 -48
  279. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +8 -2
  280. package/nitrogen/generated/android/c++/JTrackStyle.hpp +46 -18
  281. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +16 -4
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +5 -5
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -18
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → Section.kt} +18 -6
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +59 -0
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -15
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +35 -5
  297. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +158 -82
  298. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +25 -10
  299. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +25 -10
  300. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  301. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  302. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  303. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  304. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  305. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  306. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +5 -5
  307. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  308. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  309. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +54 -83
  310. package/nitrogen/generated/ios/swift/Section.swift +100 -0
  311. package/nitrogen/generated/ios/swift/SectionStyle.swift +133 -0
  312. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  313. package/nitrogen/generated/ios/swift/Track.swift +20 -73
  314. package/nitrogen/generated/ios/swift/TrackStyle.swift +86 -22
  315. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  316. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  317. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  318. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  319. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  320. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  321. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -6
  322. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  323. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  324. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +30 -38
  325. package/nitrogen/generated/shared/c++/Section.hpp +106 -0
  326. package/nitrogen/generated/shared/c++/SectionStyle.hpp +126 -0
  327. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  328. package/nitrogen/generated/shared/c++/Track.hpp +15 -31
  329. package/nitrogen/generated/shared/c++/TrackStyle.hpp +73 -34
  330. package/package.json +7 -2
  331. package/src/AudioBrowser.ts +1 -0
  332. package/src/features/browser-config.test.ts +21 -6
  333. package/src/features/browser-config.ts +11 -8
  334. package/src/features/browser.test.ts +1 -1
  335. package/src/features/browser.ts +2 -2
  336. package/src/features/favorites.ts +13 -10
  337. package/src/features/hook-wiring.test.ts +63 -15
  338. package/src/features/playback/controls.ts +4 -2
  339. package/src/features/playback/progress.ts +2 -1
  340. package/src/features/playback/rate.ts +8 -1
  341. package/src/features/playback/state.ts +2 -3
  342. package/src/features/playback/trackPlaybackTime.ts +1 -1
  343. package/src/features/player/options.ts +27 -19
  344. package/src/features/player/setup.test.ts +12 -3
  345. package/src/features/player/setup.ts +3 -1
  346. package/src/features/player/validateOptions.ts +2 -0
  347. package/src/features/queue/activeTrack.ts +14 -3
  348. package/src/features/queue/queue.ts +11 -4
  349. package/src/nitro-stub-drift.test.ts +40 -0
  350. package/src/public-surface.test.ts +40 -0
  351. package/src/specs/audio-browser.nitro.ts +4 -3
  352. package/src/style-resolution.test.ts +210 -0
  353. package/src/types/browser-native.ts +2 -2
  354. package/src/types/browser-nodes.ts +377 -135
  355. package/src/types/browser.ts +34 -55
  356. package/src/utils/LazyNativeEmitter.ts +20 -10
  357. package/src/utils/NativeUpdatedValue.ts +29 -13
  358. package/src/utils/getTrackIdentity.ts +20 -0
  359. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  360. package/src/web/NativeAudioBrowser.ts +37 -22
  361. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  362. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  363. package/src/web/browser/BrowserManager.ts +176 -95
  364. package/src/web/browser/FavoriteManager.test.ts +84 -0
  365. package/src/web/browser/FavoriteManager.ts +28 -23
  366. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  367. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  368. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  369. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  370. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  371. package/src/web/browser/sections.test.ts +91 -0
  372. package/src/web/browser/sections.ts +72 -0
  373. package/src/web/player/NowPlayingManager.ts +4 -3
  374. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  375. package/src/web/util/BrowserPathHelper.ts +61 -13
  376. package/website/guide/android-auto.md +114 -0
  377. package/website/guide/android-certificates.md +409 -0
  378. package/website/guide/artwork.md +255 -0
  379. package/website/guide/audio-output.md +135 -0
  380. package/website/guide/automotive.md +86 -0
  381. package/website/guide/basic-usage.md +151 -0
  382. package/website/guide/battery.md +115 -0
  383. package/website/guide/browser.md +581 -0
  384. package/website/guide/carplay.md +620 -0
  385. package/website/guide/configuration.md +173 -0
  386. package/website/guide/equalizer.md +231 -0
  387. package/website/guide/errors.md +310 -0
  388. package/website/guide/favorites.md +154 -0
  389. package/website/guide/gate.md +143 -0
  390. package/website/guide/getting-started.md +46 -0
  391. package/website/guide/hooks.md +238 -0
  392. package/website/guide/live-streams.md +125 -0
  393. package/website/guide/metadata.md +239 -0
  394. package/website/guide/migrating-from-track-player.md +244 -0
  395. package/website/guide/native-callback-fetch.md +127 -0
  396. package/website/guide/network.md +86 -0
  397. package/website/guide/now-playing.md +175 -0
  398. package/website/guide/playback.md +328 -0
  399. package/website/guide/queue.md +300 -0
  400. package/website/guide/remote-controls.md +256 -0
  401. package/website/guide/search.md +255 -0
  402. package/website/guide/sleep-timer.md +268 -0
  403. package/website/guide/track.md +246 -0
  404. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  405. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  406. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  407. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  408. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  409. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  410. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  411. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  412. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  413. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  414. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  415. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  416. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
@@ -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'")
@@ -496,6 +562,10 @@ class BrowserManager {
496
562
  /** Clears all cached content. */
497
563
  fun clearContentCache() {
498
564
  contentCache.evictAll()
565
+ // The search slot is content too: a locale switch or a re-auth changes titles, src, artwork
566
+ // and availability, and nothing else ever evicts it, so a repeated query returned
567
+ // pre-invalidation results forever. (Matches the Swift twin's clearContentCache.)
568
+ lastSearch = null
499
569
  // Bump the layer generation so request/browse resolver thunks are re-resolved on the next
500
570
  // request (invalidateAllContent → clearContentCache is the documented re-resolve trigger).
501
571
  layerGeneration += 1
@@ -524,81 +594,71 @@ class BrowserManager {
524
594
  effectiveArtworkConfig = config.artwork
525
595
  }
526
596
 
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 ->
597
+ // Normalize to the canonical sectioned shape (children sugar → one untitled
598
+ // section — ADR 0010), then generate contextual paths and transform artwork
599
+ // URLs. The output never carries `children`.
600
+ val sections = resolvedTrack.normalizedSections ?: return resolvedTrack
601
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each level is
602
+ // still attributable. Every page passes here whatever its source, and only on a
603
+ // cache miss. No-op outside debug builds.
604
+ InertStyleDiagnostic.warn(path, resolvedTrack.style, sections)
605
+ // The contextual index is the flat page position — children concatenated in
606
+ // section order (ADR 0009/0010).
607
+ var flatIndex = 0
608
+ // One scope for the whole page: every child's transform (which can suspend
609
+ // on artwork resolution) launches up front across all sections, then the
610
+ // sections reassemble — a slow section doesn't serialize the ones after it.
611
+ val transformedSections = coroutineScope {
612
+ sections
613
+ .map { section ->
614
+ val base = flatIndex
615
+ flatIndex += section.children.size
616
+ section to
617
+ section.children.mapIndexed { offset, track ->
533
618
  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")
619
+ val index = base + offset
620
+ // Validate that track has stable identifier.
621
+ validateTrack(track, "Child track")
539
622
 
540
623
  var transformedTrack = track
541
624
 
542
- // Generate contextual URLs for playable tracks
625
+ // Generate contextual paths for playable tracks
543
626
  // Always regenerate to reflect the current browsing context, not the original
544
627
  // context
545
628
  // (e.g., a track favorited from an album should use /favorites context when browsed
546
629
  // there)
547
- if (track.src != null) {
548
- val contextualUrl = BrowserPathHelper.build(path, track.src)
549
- transformedTrack = transformedTrack.copy(url = contextualUrl)
630
+ // The flat page position rides along as a duplicate-identity tie-breaker.
631
+ val trackIdentity = track.identity
632
+ if (track.src != null && trackIdentity != null) {
633
+ val contextualPath = BrowserPathHelper.build(path, trackIdentity, index)
634
+ transformedTrack = transformedTrack.copy(path = contextualPath)
550
635
 
551
636
  Timber.d(
552
- "[$path] Child[$index] '${track.title}': Playable, contextualUrl=$contextualUrl (src=${track.src})"
637
+ "[$path] Child[$index] '${track.title}': Playable, contextualPath=$contextualPath (identity=$trackIdentity)"
553
638
  )
554
639
  } else {
555
640
  Timber.d(
556
- "[$path] Child[$index] '${track.title}': Browsable with url=${track.url}"
641
+ "[$path] Child[$index] '${track.title}': Browsable with path=${track.path}"
557
642
  )
558
643
  }
559
644
 
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
645
  // 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
646
+ transformArtworkUrl(
647
+ transformedTrack,
648
+ effectiveArtworkConfig,
649
+ path,
650
+ index,
651
+ ImageContext(null, null),
652
+ )
590
653
  }
591
654
  }
592
- .awaitAll()
593
655
  }
594
- }
595
-
596
- // Return resolved track with transformed children
597
- return if (transformedChildren != null) {
598
- resolvedTrack.copy(children = transformedChildren.toTypedArray())
599
- } else {
600
- resolvedTrack
656
+ .map { (section, transformedChildren) ->
657
+ section.copy(children = transformedChildren.awaitAll().toTypedArray())
658
+ }
601
659
  }
660
+
661
+ return resolvedTrack.copy(sections = transformedSections.toTypedArray(), children = null)
602
662
  }
603
663
 
604
664
  /**
@@ -657,55 +717,68 @@ class BrowserManager {
657
717
  }
658
718
 
659
719
  /**
660
- * Expands a contextual URL into a queue of playable tracks.
720
+ * Expands a contextual path into a queue of playable tracks.
661
721
  *
662
722
  * Used when navigating to a track to load it with its full album/playlist context. Returns only
663
723
  * the selected track if singleTrack is true.
664
724
  *
665
- * @param contextualUrl The contextual URL (e.g., "/album?__trackId=song.mp3")
725
+ * @param contextualPath The contextual path (e.g., "/album?__trackId=song.mp3")
666
726
  * @return Pair of (tracks array, selected track index), or null if expansion fails
667
727
  */
668
- suspend fun expandQueueFromContextualUrl(contextualUrl: String): Pair<Array<Track>, Int>? {
669
- val trackId = BrowserPathHelper.extractTrackId(contextualUrl) ?: return null
728
+ suspend fun expandQueueFromContextualPath(contextualPath: String): Pair<Array<Track>, Int>? {
729
+ val trackId = BrowserPathHelper.extractTrackId(contextualPath) ?: return null
670
730
 
671
- Timber.d("Expanding queue from contextual URL: $contextualUrl (trackId=$trackId)")
731
+ Timber.d("Expanding queue from contextual path: $contextualPath (trackId=$trackId)")
672
732
 
673
733
  try {
674
734
  // Resolve the parent container to get all siblings
675
- val parentPath = BrowserPathHelper.stripTrackId(contextualUrl)
735
+ val parentPath = BrowserPathHelper.stripTrackId(contextualPath)
676
736
  val parentResolvedTrack = resolve(parentPath)
677
- val children = parentResolvedTrack.children
737
+ val sections = parentResolvedTrack.normalizedSections
678
738
 
679
- if (children.isNullOrEmpty()) {
739
+ if (sections.isNullOrEmpty()) {
680
740
  Timber.w("Parent has no children, cannot expand queue")
681
741
  return null
682
742
  }
683
743
 
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 }
744
+ // Queue scope is the tapped section, not the whole page (ADR 0006). The
745
+ // stamped flat index pins which section (and which copy) was tapped when
746
+ // the same identity appears more than once; it is only a tie-breaker — a
747
+ // stale index falls back to the first identity match. An id that no
748
+ // longer appears on the page at all aborts the expansion — the caller
749
+ // falls back to the stored single track; silently queueing the changed
750
+ // list would resume the wrong station.
751
+ val tappedIndex = BrowserPathHelper.extractIndex(contextualPath)
752
+ val scoped = SectionScope.scoped(sections, trackId, tappedIndex) ?: return null
753
+ val sectionTracks = scoped.section.children.toList()
754
+ val tappedOffset = scoped.tappedOffset
755
+
756
+ // Filter to only playable tracks (tracks with src). A disabled track is
757
+ // unavailable — queue expansion excludes it, so auto-advance never meets
758
+ // one.
759
+ val playableTracks =
760
+ sectionTracks.filter { track -> track.src != null && track.disabled != true }
698
761
 
699
762
  if (playableTracks.isEmpty()) {
700
763
  Timber.w("Parent has no playable tracks, cannot expand queue")
701
764
  return null
702
765
  }
703
766
 
704
- // Find the index of the selected track in the playable tracks array
705
- val selectedIndex = playableTracks.indexOfFirst { track -> track.src == trackId }
767
+ // Find the index of the selected track in the playable tracks array:
768
+ // the pinned copy when the stamp survived, else the first identity match
769
+ val selectedIndex =
770
+ if (
771
+ tappedOffset != null &&
772
+ sectionTracks[tappedOffset].src != null &&
773
+ sectionTracks[tappedOffset].disabled != true
774
+ ) {
775
+ sectionTracks.take(tappedOffset + 1).count { it.src != null && it.disabled != true } - 1
776
+ } else {
777
+ playableTracks.indexOfFirst { track -> track.identity == trackId }
778
+ }
706
779
 
707
780
  if (selectedIndex < 0) {
708
- Timber.w("Track with src='$trackId' not found in playable children")
781
+ Timber.w("Track with identity='$trackId' not found in playable children")
709
782
  return null
710
783
  }
711
784
 
@@ -716,11 +789,11 @@ class BrowserManager {
716
789
  }
717
790
 
718
791
  Timber.d(
719
- "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${children.size} total), starting at index $selectedIndex"
792
+ "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${sectionTracks.size} in section), starting at index $selectedIndex"
720
793
  )
721
794
  return Pair(playableTracks.toTypedArray(), selectedIndex)
722
795
  } catch (e: Exception) {
723
- Timber.e(e, "Error expanding queue from contextual URL: $contextualUrl")
796
+ Timber.e(e, "Error expanding queue from contextual path: $contextualPath")
724
797
  return null
725
798
  }
726
799
  }
@@ -822,7 +895,10 @@ class BrowserManager {
822
895
  */
823
896
  suspend fun searchPlayable(params: SearchParams): Array<Track>? {
824
897
  val searchResults = search(params)
825
- val tracks = searchResults.children
898
+ // Voice search never matches a disabled track (Track.disabled) — filtered
899
+ // before the first result is picked, so an unavailable one can't capture
900
+ // the play.
901
+ val tracks = searchResults.flattenedChildren?.filter { it.disabled != true }?.toTypedArray()
826
902
 
827
903
  if (tracks.isNullOrEmpty()) {
828
904
  return null
@@ -832,13 +908,13 @@ class BrowserManager {
832
908
 
833
909
  // Check if result is browsable-only (container/route) vs playable
834
910
  // If it's browsable but also playable (has src or playable=true), treat it as playable
835
- val firstResultUrl = firstResult.url
911
+ val firstResultPath = firstResult.path
836
912
  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 }
913
+ if (firstResult.src == null && firstResultPath != null) {
914
+ Timber.d("First search result is browsable-only, resolving: $firstResultPath")
915
+ val resolvedTrack = resolve(firstResultPath)
916
+ resolvedTrack.flattenedChildren
917
+ ?.filter { it.src != null && it.disabled != true }
842
918
  ?.takeIf { it.isNotEmpty() }
843
919
  ?.toTypedArray() ?: tracks
844
920
  } else {
@@ -883,34 +959,35 @@ class BrowserManager {
883
959
  val searchPath = BrowserPathHelper.createSearchPath(params.query)
884
960
 
885
961
  try {
886
- // Execute search. Drop results without a stable identifier (url or src): search results come
962
+ // Execute search. Drop results without a stable identifier (path or src): search results come
887
963
  // from server/JS data that doesn't pass through validateTrack like browse children do, and
888
964
  // downstream conversion (TrackFactory.toMedia3) and browsable-result resolution require one.
889
965
  val searchResults =
890
966
  resolveSearch(params)
891
967
  .filter { track ->
892
- val valid = track.url != null || track.src != null
968
+ val valid = track.path != null || track.src != null
893
969
  if (!valid) {
894
- Timber.w("Dropping search result without url or src: '${track.title}'")
970
+ Timber.w("Dropping search result without path or src: '${track.title}'")
895
971
  }
896
972
  valid
897
973
  }
898
974
  .toTypedArray()
899
975
 
900
- // Create ResolvedTrack
976
+ // Search results are a flat list; as a resolved page they are one
977
+ // untitled section (ADR 0010).
901
978
  val searchResolvedTrack =
902
979
  ResolvedTrack(
903
980
  id = null,
904
- url = searchPath,
981
+ path = searchPath,
905
982
  title = "Search: ${params.query}",
906
- children = searchResults,
983
+ sections = arrayOf(untitledSection(searchResults)),
984
+ children = null,
907
985
  carPlaySiriListButton = null,
908
986
  artwork = null,
909
987
  artworkSource = null,
910
988
  request = null,
911
- artworkCarPlayTinted = null,
912
989
  artist = null,
913
- albumUrl = null,
990
+ albumPath = null,
914
991
  description = null,
915
992
  subtitle = null,
916
993
  album = null,
@@ -918,11 +995,9 @@ class BrowserManager {
918
995
  duration = null,
919
996
  src = null,
920
997
  style = null,
921
- childrenStyle = null,
998
+ disabled = null,
922
999
  favorited = null,
923
- groupTitle = null,
924
1000
  live = null,
925
- imageRow = null,
926
1001
  )
927
1002
 
928
1003
  // Cache search results for getCachedSearchResults()
@@ -942,16 +1017,16 @@ class BrowserManager {
942
1017
  val emptySearchResult =
943
1018
  ResolvedTrack(
944
1019
  id = null,
945
- url = searchPath,
1020
+ path = searchPath,
946
1021
  title = "Search: ${params.query}",
947
- children = emptyArray(),
1022
+ sections = arrayOf(untitledSection(emptyArray())),
1023
+ children = null,
948
1024
  carPlaySiriListButton = null,
949
1025
  artwork = null,
950
1026
  artworkSource = null,
951
1027
  request = null,
952
- artworkCarPlayTinted = null,
953
1028
  artist = null,
954
- albumUrl = null,
1029
+ albumPath = null,
955
1030
  description = null,
956
1031
  subtitle = null,
957
1032
  album = null,
@@ -959,11 +1034,9 @@ class BrowserManager {
959
1034
  duration = null,
960
1035
  src = null,
961
1036
  style = null,
962
- childrenStyle = null,
1037
+ disabled = null,
963
1038
  favorited = null,
964
- groupTitle = null,
965
1039
  live = null,
966
- imageRow = null,
967
1040
  )
968
1041
 
969
1042
  return emptySearchResult
@@ -1040,7 +1113,7 @@ class BrowserManager {
1040
1113
  // Validate tabs have stable identifiers
1041
1114
  tabs.forEachIndexed { index, tab ->
1042
1115
  validateTrack(tab, "Tab")
1043
- Timber.d("[TABS] Tab[$index] '${tab.title}': url=${tab.url}")
1116
+ Timber.d("[TABS] Tab[$index] '${tab.title}': path=${tab.path}")
1044
1117
  }
1045
1118
 
1046
1119
  this.tabs = tabs
@@ -1178,7 +1251,8 @@ class BrowserManager {
1178
1251
  Timber.d("Resolving tabs via route entry")
1179
1252
  val resolvedTrack = resolveRouteEntry(tabsEntry, TABS_ROUTE_PATH, emptyMap())
1180
1253
 
1181
- return resolvedTrack.children ?: emptyArray()
1254
+ // Tabs are a flat list, not a page — a sectioned tabs source flattens.
1255
+ return resolvedTrack.flattenedChildren?.toTypedArray() ?: emptyArray()
1182
1256
  }
1183
1257
 
1184
1258
  /**