react-native-audio-browser 0.2.0-next.2 → 0.2.0-next.20

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 (660) hide show
  1. package/README.md +35 -21
  2. package/android/build.gradle +35 -12
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +389 -164
  6. package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
  7. package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
  8. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
  9. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +315 -213
  10. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +24 -5
  11. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  12. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +97 -58
  13. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +70 -29
  14. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +60 -0
  15. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +20 -7
  16. package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
  17. package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
  18. package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
  19. package/android/src/main/java/com/audiobrowser/model/IcyTitle.kt +24 -0
  20. package/android/src/main/java/com/audiobrowser/model/PlaybackMetadata.kt +10 -6
  21. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +12 -12
  22. package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
  23. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  24. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  25. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +509 -230
  26. package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
  27. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -25
  28. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +113 -78
  29. package/android/src/main/java/com/audiobrowser/player/Player.kt +81 -38
  30. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  31. package/android/src/main/java/com/audiobrowser/player/PlayerListener.kt +1 -1
  32. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  33. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  34. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +67 -11
  35. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
  36. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  37. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
  38. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
  39. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  40. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +56 -40
  41. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  42. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +108 -0
  43. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +56 -52
  44. package/android/src/main/res/values/strings.xml +25 -5
  45. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  46. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +119 -33
  47. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  48. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +15 -0
  49. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +346 -8
  50. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  51. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  52. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +3 -2
  53. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +147 -0
  54. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -55
  55. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +154 -0
  56. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  57. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  58. package/android/src/test/java/com/audiobrowser/model/IcyTitleTest.kt +49 -0
  59. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  60. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +101 -58
  61. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  62. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  63. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  64. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  65. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  66. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  67. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  68. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
  69. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
  70. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  71. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  72. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  73. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +162 -0
  74. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  75. package/ios/Browser/BrowserConfig.swift +102 -5
  76. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  77. package/ios/Browser/BrowserManager.swift +230 -205
  78. package/ios/Browser/BrowserPathHelper.swift +44 -44
  79. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  80. package/ios/Browser/JsonModels.swift +264 -106
  81. package/ios/Browser/SearchDrillIn.swift +7 -7
  82. package/ios/Browser/SectionPresentation.swift +64 -0
  83. package/ios/Browser/SectionScope.swift +58 -24
  84. package/ios/Browser/StyleResolver.swift +58 -0
  85. package/ios/Browser/TabBarEntries.swift +9 -3
  86. package/ios/Browser/TrackIdTemplate.swift +37 -0
  87. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  88. package/ios/CarPlay/CarPlayController.swift +227 -85
  89. package/ios/CarPlay/CarPlayImageLoader.swift +42 -20
  90. package/ios/CarPlay/CarPlayListItemFactory.swift +289 -84
  91. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  92. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  93. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  94. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  95. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  96. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  97. package/ios/Extension/ResolvedTrack+Copying.swift +13 -15
  98. package/ios/Extension/Section+Copying.swift +29 -0
  99. package/ios/Extension/String+IcyCharset.swift +21 -0
  100. package/ios/Extension/TimedMetadata+AVFoundation.swift +6 -4
  101. package/ios/Extension/Track+Copying.swift +8 -12
  102. package/ios/Extension/Track+Identity.swift +18 -0
  103. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  104. package/ios/Http/HttpClient.swift +8 -2
  105. package/ios/HybridAudioBrowser.swift +253 -100
  106. package/ios/Model/NitroTypeStubs.swift +125 -23
  107. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  108. package/ios/Model/RemoteCommand.swift +18 -0
  109. package/ios/Model/TrackPlayerError.swift +2 -2
  110. package/ios/Option/SessionCategories.swift +3 -2
  111. package/ios/Player/MediaIntentCriteria.swift +17 -9
  112. package/ios/Player/MediaLoader.swift +33 -9
  113. package/ios/Player/NowPlayingUpdater.swift +54 -6
  114. package/ios/Player/PlaybackCoordinator.swift +13 -6
  115. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  116. package/ios/Player/PlaybackTimer.swift +8 -2
  117. package/ios/Player/QueueManager.swift +46 -13
  118. package/ios/Player/RetryManager.swift +80 -18
  119. package/ios/Player/SeekCompletionHandler.swift +3 -1
  120. package/ios/Player/ShuffleOrder.swift +33 -0
  121. package/ios/Player/SleepTimerHandling.swift +13 -0
  122. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  123. package/ios/RemoteCommand/RemoteCommandController.swift +62 -24
  124. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  125. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  126. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  127. package/ios/Tests/JsonModelsTests.swift +150 -1
  128. package/ios/Tests/LRUCacheTests.swift +111 -0
  129. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  130. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  131. package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
  132. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  133. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  134. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  135. package/ios/Tests/QueueManagerTests.swift +122 -10
  136. package/ios/Tests/RemoteCommandControllerTests.swift +172 -0
  137. package/ios/Tests/RemoteCommandTests.swift +67 -0
  138. package/ios/Tests/ResolvedTrackCopyingTests.swift +39 -35
  139. package/ios/Tests/RetryManagerTests.swift +89 -0
  140. package/ios/Tests/SearchDrillInTests.swift +16 -16
  141. package/ios/Tests/SectionPresentationTests.swift +90 -0
  142. package/ios/Tests/SectionScopeTests.swift +87 -55
  143. package/ios/Tests/StyleResolverTests.swift +127 -0
  144. package/ios/Tests/Support/TestTypes.swift +8 -1
  145. package/ios/Tests/TabBarEntriesTests.swift +19 -19
  146. package/ios/Tests/TimedMetadataAVFoundationTests.swift +42 -0
  147. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  148. package/ios/Tests/TrackIdentityTests.swift +20 -0
  149. package/ios/Tests/TrackSelectorTests.swift +168 -33
  150. package/ios/TrackPlayer.swift +63 -35
  151. package/ios/TrackPlayerCallbacks.swift +5 -38
  152. package/ios/TrackSelector.swift +45 -30
  153. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  154. package/ios/Util/LRUCache.swift +4 -1
  155. package/ios/Util/NetworkMonitor.swift +43 -26
  156. package/ios/Util/SFSymbolRenderer.swift +24 -2
  157. package/ios/Util/SVGProcessor.swift +9 -2
  158. package/lib/commonjs/AudioBrowser.js +8 -8
  159. package/lib/commonjs/AudioBrowser.js.map +1 -1
  160. package/lib/commonjs/CLAUDE.md +52 -0
  161. package/lib/commonjs/features/browser-config.js +12 -8
  162. package/lib/commonjs/features/browser-config.js.map +1 -1
  163. package/lib/commonjs/features/browser.js +11 -3
  164. package/lib/commonjs/features/browser.js.map +1 -1
  165. package/lib/commonjs/features/carConnection.js +1 -1
  166. package/lib/commonjs/features/equalizer.js +1 -1
  167. package/lib/commonjs/features/errors.js +3 -3
  168. package/lib/commonjs/features/favorites.js +14 -11
  169. package/lib/commonjs/features/favorites.js.map +1 -1
  170. package/lib/commonjs/features/gate.js +16 -1
  171. package/lib/commonjs/features/gate.js.map +1 -1
  172. package/lib/commonjs/features/metadata.js +20 -3
  173. package/lib/commonjs/features/metadata.js.map +1 -1
  174. package/lib/commonjs/features/network.js +1 -1
  175. package/lib/commonjs/features/nowPlaying.js +1 -1
  176. package/lib/commonjs/features/output.js +1 -1
  177. package/lib/commonjs/features/playback/controls.js +4 -2
  178. package/lib/commonjs/features/playback/controls.js.map +1 -1
  179. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  180. package/lib/commonjs/features/playback/playing.js +1 -1
  181. package/lib/commonjs/features/playback/progress.js +3 -2
  182. package/lib/commonjs/features/playback/progress.js.map +1 -1
  183. package/lib/commonjs/features/playback/rate.js +8 -1
  184. package/lib/commonjs/features/playback/rate.js.map +1 -1
  185. package/lib/commonjs/features/playback/state.js +3 -4
  186. package/lib/commonjs/features/playback/state.js.map +1 -1
  187. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  188. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  189. package/lib/commonjs/features/playback/volume.js +1 -1
  190. package/lib/commonjs/features/player/options.js +48 -34
  191. package/lib/commonjs/features/player/options.js.map +1 -1
  192. package/lib/commonjs/features/player/setup.js +18 -9
  193. package/lib/commonjs/features/player/setup.js.map +1 -1
  194. package/lib/commonjs/features/player/validateOptions.js +23 -0
  195. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  196. package/lib/commonjs/features/queue/activeTrack.js +15 -4
  197. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  198. package/lib/commonjs/features/queue/queue.js +13 -6
  199. package/lib/commonjs/features/queue/queue.js.map +1 -1
  200. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  201. package/lib/commonjs/features/queue/shuffle.js +1 -1
  202. package/lib/commonjs/features/remoteControls.js +13 -92
  203. package/lib/commonjs/features/remoteControls.js.map +1 -1
  204. package/lib/commonjs/features/sleepTimer.js +13 -1
  205. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  206. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  207. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  208. package/lib/commonjs/utils/LazyNativeEmitter.js +28 -14
  209. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  210. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  211. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  212. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  213. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  214. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  215. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  216. package/lib/commonjs/web/NativeAudioBrowser.js +200 -75
  217. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  218. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  219. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  220. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  221. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  222. package/lib/commonjs/web/TrackPlayer/Player.js +254 -39
  223. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  224. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  225. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  226. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  227. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  228. package/lib/commonjs/web/browser/BrowserManager.js +175 -100
  229. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  230. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  231. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  232. package/lib/commonjs/web/browser/SearchManager.js +18 -23
  233. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  234. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  235. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  236. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  237. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  238. package/lib/commonjs/web/browser/sections.js +89 -0
  239. package/lib/commonjs/web/browser/sections.js.map +1 -0
  240. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  241. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  242. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  243. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  244. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  245. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  246. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  247. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  248. package/lib/module/AudioBrowser.js +2 -1
  249. package/lib/module/AudioBrowser.js.map +1 -1
  250. package/lib/module/CLAUDE.md +52 -0
  251. package/lib/module/features/browser-config.js +12 -8
  252. package/lib/module/features/browser-config.js.map +1 -1
  253. package/lib/module/features/browser.js +11 -3
  254. package/lib/module/features/browser.js.map +1 -1
  255. package/lib/module/features/carConnection.js +1 -1
  256. package/lib/module/features/equalizer.js +1 -1
  257. package/lib/module/features/errors.js +3 -3
  258. package/lib/module/features/favorites.js +14 -11
  259. package/lib/module/features/favorites.js.map +1 -1
  260. package/lib/module/features/gate.js +16 -1
  261. package/lib/module/features/gate.js.map +1 -1
  262. package/lib/module/features/metadata.js +20 -3
  263. package/lib/module/features/metadata.js.map +1 -1
  264. package/lib/module/features/network.js +1 -1
  265. package/lib/module/features/nowPlaying.js +1 -1
  266. package/lib/module/features/output.js +1 -1
  267. package/lib/module/features/playback/controls.js +4 -2
  268. package/lib/module/features/playback/controls.js.map +1 -1
  269. package/lib/module/features/playback/playWhenReady.js +1 -1
  270. package/lib/module/features/playback/playing.js +1 -1
  271. package/lib/module/features/playback/progress.js +3 -2
  272. package/lib/module/features/playback/progress.js.map +1 -1
  273. package/lib/module/features/playback/rate.js +8 -1
  274. package/lib/module/features/playback/rate.js.map +1 -1
  275. package/lib/module/features/playback/state.js +3 -4
  276. package/lib/module/features/playback/state.js.map +1 -1
  277. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  278. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  279. package/lib/module/features/playback/volume.js +1 -1
  280. package/lib/module/features/player/options.js +47 -32
  281. package/lib/module/features/player/options.js.map +1 -1
  282. package/lib/module/features/player/setup.js +17 -8
  283. package/lib/module/features/player/setup.js.map +1 -1
  284. package/lib/module/features/player/validateOptions.js +19 -0
  285. package/lib/module/features/player/validateOptions.js.map +1 -0
  286. package/lib/module/features/queue/activeTrack.js +15 -4
  287. package/lib/module/features/queue/activeTrack.js.map +1 -1
  288. package/lib/module/features/queue/queue.js +13 -6
  289. package/lib/module/features/queue/queue.js.map +1 -1
  290. package/lib/module/features/queue/repeatMode.js +1 -1
  291. package/lib/module/features/queue/shuffle.js +1 -1
  292. package/lib/module/features/remoteControls.js +13 -92
  293. package/lib/module/features/remoteControls.js.map +1 -1
  294. package/lib/module/features/sleepTimer.js +13 -1
  295. package/lib/module/features/sleepTimer.js.map +1 -1
  296. package/lib/module/test-utils/reactNativeStub.js +58 -0
  297. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  298. package/lib/module/utils/LazyNativeEmitter.js +28 -14
  299. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  300. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  301. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  302. package/lib/module/utils/getTrackIdentity.js +20 -0
  303. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  304. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  305. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  306. package/lib/module/web/NativeAudioBrowser.js +200 -75
  307. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  308. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  309. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  310. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  311. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  312. package/lib/module/web/TrackPlayer/Player.js +254 -39
  313. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  314. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  315. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  316. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  317. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  318. package/lib/module/web/browser/BrowserManager.js +175 -100
  319. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  320. package/lib/module/web/browser/FavoriteManager.js +31 -21
  321. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  322. package/lib/module/web/browser/SearchManager.js +18 -23
  323. package/lib/module/web/browser/SearchManager.js.map +1 -1
  324. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  325. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  326. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  327. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  328. package/lib/module/web/browser/sections.js +83 -0
  329. package/lib/module/web/browser/sections.js.map +1 -0
  330. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  331. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  332. package/lib/module/web/http/followMediaRedirect.js +215 -0
  333. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  334. package/lib/module/web/player/NowPlayingManager.js +4 -3
  335. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  336. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  337. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  338. package/lib/typescript/package.json +1 -0
  339. package/lib/typescript/src/AudioBrowser.d.ts +1 -1
  340. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  341. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  342. package/lib/typescript/src/features/browser.d.ts +10 -2
  343. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  344. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  345. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  346. package/lib/typescript/src/features/errors.d.ts +3 -3
  347. package/lib/typescript/src/features/favorites.d.ts +14 -11
  348. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  349. package/lib/typescript/src/features/gate.d.ts +18 -2
  350. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  351. package/lib/typescript/src/features/metadata.d.ts +20 -3
  352. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  353. package/lib/typescript/src/features/network.d.ts +1 -1
  354. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  355. package/lib/typescript/src/features/output.d.ts +1 -1
  356. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  357. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  358. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  359. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  360. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  361. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  362. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  363. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  364. package/lib/typescript/src/features/playback/state.d.ts +3 -4
  365. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  366. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  367. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  368. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  369. package/lib/typescript/src/features/player/options.d.ts +108 -56
  370. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  371. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  372. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  373. package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
  374. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  375. package/lib/typescript/src/features/queue/activeTrack.d.ts +9 -2
  376. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  377. package/lib/typescript/src/features/queue/queue.d.ts +7 -4
  378. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  379. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  380. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  381. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  382. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  383. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  384. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  385. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  386. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  387. package/lib/typescript/src/types/browser-native.d.ts +3 -2
  388. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  389. package/lib/typescript/src/types/browser-nodes.d.ts +380 -126
  390. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  391. package/lib/typescript/src/types/browser.d.ts +82 -57
  392. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  393. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +19 -16
  394. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  395. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  396. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  397. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  398. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  399. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  400. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  401. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -2
  402. package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
  403. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  404. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  405. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +8 -3
  406. package/nitrogen/generated/android/c++/JBrowseResult.hpp +16 -4
  407. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  408. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  409. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +8 -3
  410. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +8 -3
  411. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +8 -3
  412. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +8 -3
  413. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +8 -3
  414. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +8 -3
  415. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +8 -2
  416. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +8 -2
  417. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
  418. 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
  419. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +8 -3
  420. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +7 -10
  421. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +8 -3
  422. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +15 -3
  423. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +8 -2
  424. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  425. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +45 -25
  426. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  427. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  428. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  429. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +8 -3
  430. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +36 -11
  431. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +14 -2
  432. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +15 -12
  433. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +7 -10
  434. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  435. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  436. package/nitrogen/generated/android/c++/JOptions.hpp +7 -10
  437. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +8 -3
  438. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  439. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  440. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  441. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +57 -49
  442. package/nitrogen/generated/android/c++/JSection.hpp +116 -0
  443. package/nitrogen/generated/android/c++/JSectionStyle.hpp +96 -0
  444. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  445. package/nitrogen/generated/android/c++/JTrack.hpp +25 -46
  446. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +8 -2
  447. package/nitrogen/generated/android/c++/JTrackStyle.hpp +46 -18
  448. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  449. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  450. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +16 -4
  451. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  452. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  453. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  454. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  455. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
  456. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  457. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  458. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  459. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  460. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +8 -5
  461. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  462. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  463. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  464. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  465. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  466. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -15
  467. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → Section.kt} +15 -6
  468. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +59 -0
  469. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  470. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -12
  471. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +35 -5
  472. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  473. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
  474. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +244 -133
  475. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +31 -16
  476. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +33 -18
  477. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  478. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  479. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  480. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  481. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
  482. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  483. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  484. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  485. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  486. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  487. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +52 -5
  488. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  489. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  490. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  491. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  492. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  493. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +62 -73
  494. package/nitrogen/generated/ios/swift/Section.swift +82 -0
  495. package/nitrogen/generated/ios/swift/SectionStyle.swift +133 -0
  496. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  497. package/nitrogen/generated/ios/swift/Track.swift +28 -63
  498. package/nitrogen/generated/ios/swift/TrackStyle.swift +86 -22
  499. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  500. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  501. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  502. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  503. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  504. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  505. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  506. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  507. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  508. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  509. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +10 -6
  510. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  511. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  512. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  513. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  514. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  515. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +33 -37
  516. package/nitrogen/generated/shared/c++/Section.hpp +102 -0
  517. package/nitrogen/generated/shared/c++/SectionStyle.hpp +126 -0
  518. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  519. package/nitrogen/generated/shared/c++/Track.hpp +18 -30
  520. package/nitrogen/generated/shared/c++/TrackStyle.hpp +73 -34
  521. package/package.json +24 -11
  522. package/src/AudioBrowser.ts +2 -1
  523. package/src/CLAUDE.md +52 -0
  524. package/src/features/battery.test.ts +79 -0
  525. package/src/features/browser-config.test.ts +21 -6
  526. package/src/features/browser-config.ts +12 -8
  527. package/src/features/browser.test.ts +1 -1
  528. package/src/features/browser.ts +11 -3
  529. package/src/features/carConnection.ts +1 -1
  530. package/src/features/equalizer.ts +1 -1
  531. package/src/features/errors.ts +3 -3
  532. package/src/features/favorites.ts +14 -11
  533. package/src/features/gate.ts +16 -1
  534. package/src/features/hook-wiring.test.ts +302 -0
  535. package/src/features/metadata.ts +20 -3
  536. package/src/features/network.ts +1 -1
  537. package/src/features/nowPlaying.ts +1 -1
  538. package/src/features/output.ts +1 -1
  539. package/src/features/playback/controls.ts +4 -2
  540. package/src/features/playback/playWhenReady.ts +1 -1
  541. package/src/features/playback/playing.ts +1 -1
  542. package/src/features/playback/progress.test.ts +169 -0
  543. package/src/features/playback/progress.ts +3 -2
  544. package/src/features/playback/rate.ts +8 -1
  545. package/src/features/playback/state.ts +3 -4
  546. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  547. package/src/features/playback/trackPlaybackTime.ts +1 -6
  548. package/src/features/playback/volume.ts +1 -1
  549. package/src/features/player/options.ts +126 -76
  550. package/src/features/player/setup.test.ts +24 -7
  551. package/src/features/player/setup.ts +22 -10
  552. package/src/features/player/validateOptions.ts +21 -0
  553. package/src/features/queue/activeTrack.ts +15 -4
  554. package/src/features/queue/queue.ts +13 -6
  555. package/src/features/queue/repeatMode.ts +1 -1
  556. package/src/features/queue/shuffle.ts +1 -1
  557. package/src/features/remoteControls.ts +13 -92
  558. package/src/features/sleepTimer.test.ts +172 -0
  559. package/src/features/sleepTimer.ts +9 -1
  560. package/src/nitro-stub-drift.test.ts +277 -0
  561. package/src/public-surface.test.ts +147 -0
  562. package/src/specs/audio-browser.nitro.ts +5 -4
  563. package/src/style-resolution.test.ts +210 -0
  564. package/src/test-utils/reactNativeStub.ts +60 -0
  565. package/src/types/browser-native.ts +7 -3
  566. package/src/types/browser-nodes.ts +396 -134
  567. package/src/types/browser.ts +85 -59
  568. package/src/utils/LazyNativeEmitter.ts +28 -15
  569. package/src/utils/NativeUpdatedValue.ts +29 -13
  570. package/src/utils/getTrackIdentity.ts +20 -0
  571. package/src/utils/useDebug.test.ts +182 -0
  572. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  573. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  574. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  575. package/src/web/NativeAudioBrowser.ts +239 -82
  576. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  577. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  578. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  579. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  580. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  581. package/src/web/TrackPlayer/Player.ts +284 -37
  582. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  583. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  584. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  585. package/src/web/__tests__/lifecycle.test.ts +345 -0
  586. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  587. package/src/web/__tests__/loadEdgeCases.test.ts +246 -0
  588. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  589. package/src/web/__tests__/retry.test.ts +169 -0
  590. package/src/web/browser/BrowserManager.ts +209 -111
  591. package/src/web/browser/FavoriteManager.test.ts +84 -0
  592. package/src/web/browser/FavoriteManager.ts +28 -23
  593. package/src/web/browser/SearchManager.test.ts +75 -0
  594. package/src/web/browser/SearchManager.ts +25 -28
  595. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  596. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  597. package/src/web/browser/__tests__/search-artwork.test.ts +46 -0
  598. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
  599. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  600. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  601. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  602. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  603. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  604. package/src/web/browser/sections.test.ts +91 -0
  605. package/src/web/browser/sections.ts +72 -0
  606. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  607. package/src/web/http/RequestConfigBuilder.ts +140 -33
  608. package/src/web/http/followMediaRedirect.test.ts +289 -0
  609. package/src/web/http/followMediaRedirect.ts +253 -0
  610. package/src/web/player/NowPlayingManager.ts +4 -3
  611. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  612. package/src/web/util/BrowserPathHelper.ts +61 -13
  613. package/website/guide/android-auto.md +114 -0
  614. package/website/guide/android-certificates.md +409 -0
  615. package/website/guide/artwork.md +255 -0
  616. package/website/guide/audio-output.md +135 -0
  617. package/website/guide/automotive.md +86 -0
  618. package/website/guide/basic-usage.md +151 -0
  619. package/website/guide/battery.md +115 -0
  620. package/website/guide/browser.md +581 -0
  621. package/website/guide/carplay.md +620 -0
  622. package/website/guide/configuration.md +173 -0
  623. package/website/guide/equalizer.md +231 -0
  624. package/website/guide/errors.md +310 -0
  625. package/website/guide/favorites.md +154 -0
  626. package/website/guide/gate.md +143 -0
  627. package/website/guide/getting-started.md +46 -0
  628. package/website/guide/hooks.md +238 -0
  629. package/website/guide/live-streams.md +125 -0
  630. package/website/guide/metadata.md +239 -0
  631. package/website/guide/migrating-from-track-player.md +244 -0
  632. package/website/guide/native-callback-fetch.md +127 -0
  633. package/website/guide/network.md +86 -0
  634. package/website/guide/now-playing.md +175 -0
  635. package/website/guide/playback.md +328 -0
  636. package/website/guide/queue.md +300 -0
  637. package/website/guide/remote-controls.md +256 -0
  638. package/website/guide/search.md +255 -0
  639. package/website/guide/sleep-timer.md +268 -0
  640. package/website/guide/track.md +247 -0
  641. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  642. package/ios/Model/MediaURL.swift +0 -67
  643. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  644. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  645. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  646. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  647. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  648. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  649. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  650. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  651. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  652. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  653. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  654. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  655. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  656. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  657. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  658. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  659. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
  660. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -1,17 +1,17 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
- import com.audiobrowser.util.artworkOf
4
-
5
3
  import android.util.LruCache
4
+ import androidx.media3.common.C
6
5
  import androidx.media3.common.MediaItem
7
6
  import androidx.media3.session.MediaSession
7
+ import com.audiobrowser.extension.identity
8
8
  import com.audiobrowser.http.HttpClient
9
9
  import com.audiobrowser.http.RequestConfigBuilder
10
10
  import com.audiobrowser.util.BrowserPathHelper
11
11
  import com.audiobrowser.util.TrackFactory
12
+ import com.audiobrowser.util.artworkOf
12
13
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
13
14
  import com.margelo.nitro.audiobrowser.BrowserSourceCallbackParam
14
- import com.margelo.nitro.audiobrowser.FavoritesMatchMode
15
15
  import com.margelo.nitro.audiobrowser.Func_std__shared_ptr_Promise_std__shared_ptr_Promise_TransformableRequestConfig____
16
16
  import com.margelo.nitro.audiobrowser.ImageContext
17
17
  import com.margelo.nitro.audiobrowser.MediaReference
@@ -20,8 +20,11 @@ import com.margelo.nitro.audiobrowser.NativeRouteEntry
20
20
  import com.margelo.nitro.audiobrowser.RequestConfig
21
21
  import com.margelo.nitro.audiobrowser.ResolvedTrack
22
22
  import com.margelo.nitro.audiobrowser.SearchParams
23
+ import com.margelo.nitro.audiobrowser.Section
24
+ import com.margelo.nitro.audiobrowser.StyleDisplay
23
25
  import com.margelo.nitro.audiobrowser.Track
24
26
  import com.margelo.nitro.audiobrowser.TransformableRequestConfig
27
+ import com.margelo.nitro.core.Promise
25
28
  import kotlinx.coroutines.Dispatchers
26
29
  import kotlinx.coroutines.async
27
30
  import kotlinx.coroutines.awaitAll
@@ -83,7 +86,7 @@ class BrowserManager {
83
86
  }
84
87
  }
85
88
 
86
- // LRU cache for individual tracks - keyed by both url and src for O(1) lookup
89
+ // LRU cache for individual tracks - keyed by both path and src for O(1) lookup
87
90
  private val trackCache = LruCache<String, Track>(3000)
88
91
 
89
92
  // LRU cache for resolved content - keyed by path
@@ -95,12 +98,12 @@ class BrowserManager {
95
98
  // concurrent reader never sees one query matched with another query's results.
96
99
  @Volatile private var lastSearch: Pair<String, Array<Track>>? = null
97
100
 
98
- // Set of favorited track identifiers (src)
101
+ // Set of favorited track identities (id when non-blank, else src — see Track.identity)
99
102
  private var favoriteIds = setOf<String>()
100
103
 
101
- // Favorite match mode, propagated from the player's `favorite` capability.
102
- // null = favoriting disabled (no row hearts). Set via setFavoriteMatch.
103
- private var favoriteMatch: FavoritesMatchMode? = null
104
+ // Whether favoriting is enabled, propagated from the player's `favorite` capability.
105
+ // false = favoriting disabled (no row hearts). Set via setFavoriteEnabled.
106
+ private var favoriteEnabled: Boolean = false
104
107
 
105
108
  // Navigation tracking to prevent race conditions
106
109
  @Volatile private var currentNavigationId = 0
@@ -131,6 +134,12 @@ class BrowserManager {
131
134
  private var resolvedRequestLayer: TransformableRequestConfig? = null
132
135
  private var resolvedBrowseLayer: TransformableRequestConfig? = null
133
136
 
137
+ // The "view more" row title, cached on the same generation as the layers above — see
138
+ // viewMoreTitle(). Kept separate from them because it is resolved lazily (only a browse that
139
+ // actually appends a row asks for it), not eagerly alongside every request.
140
+ private var resolvedViewMoreTitleGeneration = -1
141
+ private var resolvedViewMoreTitle = DEFAULT_VIEW_MORE_TITLE
142
+
134
143
  /** Test-only accessors for the resolver-layer cache state (see ensureLayersResolved). */
135
144
  internal val layerGenerationForTest: Int
136
145
  get() = layerGeneration
@@ -151,8 +160,8 @@ class BrowserManager {
151
160
  val artworkResolutions = ArtworkResolutionRegistry()
152
161
 
153
162
  /**
154
- * Sets the favorited track identifiers. Tracks will have their favorited field hydrated based on
155
- * this list during browsing.
163
+ * Sets the favorited track identities (id when non-blank, else src). Tracks will have their
164
+ * favorited field hydrated based on this list during browsing.
156
165
  */
157
166
  fun setFavorites(favorites: List<String>) {
158
167
  favoriteIds = favorites.toSet()
@@ -160,97 +169,125 @@ class BrowserManager {
160
169
  }
161
170
 
162
171
  /**
163
- * Sets the favorite match mode (propagated from the `favorite` capability). null disables
172
+ * Enables or disables favoriting (propagated from the `favorite` capability). false disables
164
173
  * row-heart hydration.
165
174
  */
166
- fun setFavoriteMatch(match: FavoritesMatchMode?) {
167
- favoriteMatch = match
175
+ fun setFavoriteEnabled(enabled: Boolean) {
176
+ favoriteEnabled = enabled
168
177
  }
169
178
 
170
179
  /**
171
- * Updates the favorite state for a single track identifier. Called when the heart button is
172
- * tapped in media controllers.
180
+ * Updates the favorite state for a single track identity (id when non-blank, else src). Called
181
+ * when the heart button is tapped in media controllers.
173
182
  */
174
- fun updateFavorite(id: String, favorited: Boolean) {
183
+ fun updateFavorite(identity: String, favorited: Boolean) {
175
184
  favoriteIds =
176
185
  if (favorited) {
177
- favoriteIds + id
186
+ favoriteIds + identity
178
187
  } else {
179
- favoriteIds - id
188
+ favoriteIds - identity
180
189
  }
181
- Timber.d("Updated favorite for '$id' to $favorited (total: ${favoriteIds.size})")
190
+ Timber.d("Updated favorite for '$identity' to $favorited (total: ${favoriteIds.size})")
182
191
  }
183
192
 
184
193
  /**
185
194
  * Hydrates the favorited field on a track based on the favoriteIds set. No-op unless favoriting
186
- * is enabled (the `favorite` capability). Only playable (src-bearing) tracks are favoritable; the
187
- * flag is set to true OR false so non-favorited tracks still show an (empty) heart. Doesn't
188
- * overwrite API-provided values.
195
+ * is enabled (the `favorite` capability). Only tracks with an identity (id or src) are
196
+ * favoritable; the flag is set to true OR false so non-favorited tracks still show an (empty)
197
+ * heart. Doesn't overwrite API-provided values.
189
198
  */
190
199
  private fun hydrateFavorite(track: Track): Track {
191
- val match = favoriteMatch ?: return track
200
+ if (!favoriteEnabled) return track
192
201
  // Don't overwrite API-provided favorites
193
202
  if (track.favorited != null) return track
194
- // Only playable tracks are favoritable
195
- val src = track.src ?: return track
196
-
197
- val isFavorited = isFavorite(src, match)
203
+ // Only identity-bearing tracks are favoritable
204
+ if (track.identity == null) return track
198
205
 
199
- return track.copy(favorited = isFavorited)
206
+ return track.copy(favorited = isFavorite(track))
200
207
  }
201
208
 
202
- /** Whether [src] is favorited under the given match mode. */
203
- private fun isFavorite(src: String, match: FavoritesMatchMode): Boolean =
204
- when (match) {
205
- FavoritesMatchMode.EXACT -> favoriteIds.contains(src)
206
- FavoritesMatchMode.PARTIAL -> favoriteIds.any { BrowserPathHelper.containsSegment(src, it) }
207
- }
209
+ /** Whether [track]'s identity is in the favorites set. */
210
+ private fun isFavorite(track: Track): Boolean =
211
+ track.identity?.let { favoriteIds.contains(it) } == true
208
212
 
209
- /** Hydrates favorites on all children of a ResolvedTrack. */
213
+ /**
214
+ * Hydrates favorites on all tracks of a resolved page. Pages reach hydration normalized (ADR
215
+ * 0010), so `sections` is the only structure to walk.
216
+ */
210
217
  private fun hydrateChildren(resolvedTrack: ResolvedTrack): ResolvedTrack {
211
- val children = resolvedTrack.children ?: return resolvedTrack
212
- val hydratedChildren = children.map { hydrateFavorite(it) }.toTypedArray()
213
- return resolvedTrack.copy(children = hydratedChildren)
218
+ val sections = resolvedTrack.sections ?: return resolvedTrack
219
+ return resolvedTrack.copy(
220
+ sections =
221
+ sections
222
+ .map { section ->
223
+ section.copy(children = section.children.map { hydrateFavorite(it) }.toTypedArray())
224
+ }
225
+ .toTypedArray()
226
+ )
214
227
  }
215
228
 
216
- /** Cache a track by id, url, and src for O(1) lookup from any mediaId form. */
229
+ /** Cache a track by id, path, and src for O(1) lookup from any mediaId form. */
217
230
  private fun cacheTrack(track: Track) {
218
231
  track.id?.takeUnless { it.isBlank() }?.let { trackCache.put(it, track) }
219
- track.url?.let { trackCache.put(it, track) }
232
+ track.path?.let { trackCache.put(it, track) }
220
233
  track.src?.let { trackCache.put(it, track) }
221
234
  }
222
235
 
223
236
  private fun cacheChildren(resolvedTrack: ResolvedTrack) {
224
- resolvedTrack.children?.forEach { track ->
225
- cacheTrack(track)
226
- // Image-row items are playable surfaces of their own: cache them (with
227
- // their stamped contextual urls) so an id-keyed mediaId from an
228
- // expanded Android Auto tile can find its way back to queue expansion.
229
- track.imageRow?.forEach { item ->
230
- cacheTrack(with(TrackFactory) { item.toTrack(groupTitle = track.title) })
231
- }
237
+ resolvedTrack.flattenedChildren?.forEach { cacheTrack(it) }
238
+ }
239
+
240
+ // Pages already warned about by warnIfGridPageLacksPromise — the mismatch
241
+ // re-observes on every onGetChildren serve, and once is a diagnostic while
242
+ // every-scroll is noise. Concurrent: onGetChildren serves run on the IO
243
+ // pool, several in flight at once.
244
+ private val promiseWarnedPaths = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
245
+
246
+ /**
247
+ * Dev diagnostic (ADR 0011): a page serving grid sections under a browsable handle that declared
248
+ * no `style.display` promise still renders its parent-level layout as a list on Android Auto —
249
+ * the parent hint is emitted only when declared, never derived from resolved pages and never
250
+ * back-filled. Warns at the one site where the mismatch is observable; a cache miss for the
251
+ * handle (cold start straight into the page) just skips the check.
252
+ *
253
+ * Only an all-grid page warns: the parent-level hint lays out the WHOLE page, so on a mixed page
254
+ * (a teaser shelf above list sections) there is no correct promise to advise — grid would tile
255
+ * the list sections too.
256
+ */
257
+ fun warnIfGridPageLacksPromise(path: String, sections: List<Section>) {
258
+ if (sections.isEmpty() || !sections.all { it.style?.display == StyleDisplay.GRID }) return
259
+ val handle = trackCache.get(path) ?: return
260
+ if (handle.src != null) return // rendered playable — opens no page, promises nothing
261
+ if (handle.style?.display == null && promiseWarnedPaths.add(path)) {
262
+ Timber.w(
263
+ "Page '%s' serves grid sections, but its browsable handle declares no style.display — " +
264
+ "Android Auto's parent-level hint was not emitted, so this page renders as a list " +
265
+ "there. Declare `style: { display: 'grid' }` on the handle track.",
266
+ path,
267
+ )
232
268
  }
233
269
  }
234
270
 
235
271
  /**
236
- * Get a cached Track by mediaId (stable id, url, or src), or null if not cached. Used by Media3
272
+ * Get a cached Track by mediaId (stable id, path, or src), or null if not cached. Used by Media3
237
273
  * to rehydrate MediaItem shells with full track metadata. Re-hydrates favorites in case
238
274
  * setFavoriteStates was called after caching.
239
275
  */
240
276
  fun getCachedTrack(mediaId: String): Track? {
241
- // Try direct lookup first (matches url or src)
277
+ // Try direct lookup first (matches path or src)
242
278
  trackCache.get(mediaId)?.let { track ->
243
279
  val hydratedTrack = hydrateFavorite(track)
244
280
  Timber.d("Cache HIT for mediaId='$mediaId' → '${track.title}'")
245
281
  return hydratedTrack
246
282
  }
247
283
 
248
- // Try extracting src from contextual URL
284
+ // Try extracting the track identity (id when non-blank, else src) from a contextual path —
285
+ // the cache is keyed by id AND src, so either identity form resolves.
249
286
  val trackId = BrowserPathHelper.extractTrackId(mediaId)
250
287
  if (trackId != null) {
251
288
  trackCache.get(trackId)?.let { track ->
252
289
  val hydratedTrack = hydrateFavorite(track)
253
- Timber.d("Cache HIT (extracted src) for mediaId='$mediaId' → '${track.title}'")
290
+ Timber.d("Cache HIT (extracted identity) for mediaId='$mediaId' → '${track.title}'")
254
291
  return hydratedTrack
255
292
  }
256
293
  }
@@ -260,19 +297,19 @@ class BrowserManager {
260
297
  }
261
298
 
262
299
  /**
263
- * Resolves a tapped mediaId to the contextual url to expand a queue from, or null when there is
300
+ * Resolves a tapped mediaId to the contextual path to expand a queue from, or null when there is
264
301
  * none. A contextual mediaId is its own; a stable-id mediaId (see TrackFactory.buildMediaItem)
265
- * resolves through the track cache to the contextual url of the container it was most recently
302
+ * resolves through the track cache to the contextual path of the container it was most recently
266
303
  * browsed in — a legacy car controller round-trips only the mediaId, so the cache is what
267
304
  * remembers which list the row came from.
268
305
  */
269
- fun contextualUrlFor(mediaId: String): String? {
306
+ fun contextualPathFor(mediaId: String): String? {
270
307
  if (BrowserPathHelper.isContextual(mediaId)) return mediaId
271
- return getCachedTrack(mediaId)?.url?.takeIf { BrowserPathHelper.isContextual(it) }
308
+ return getCachedTrack(mediaId)?.path?.takeIf { BrowserPathHelper.isContextual(it) }
272
309
  }
273
310
 
274
311
  /**
275
- * Resolves a single Media3 MediaItem to a Track. Prefers the track cache (keyed by id, url, and
312
+ * Resolves a single Media3 MediaItem to a Track. Prefers the track cache (keyed by id, path, and
276
313
  * src).
277
314
  *
278
315
  * A cache miss is legitimately reachable: a controller can replay a mediaId this process never
@@ -305,26 +342,24 @@ class BrowserManager {
305
342
  // A mediaId distinct from the playable uri is the track's stable id —
306
343
  // keep it so the item's identity (car now-playing row match) survives.
307
344
  id = mediaId.takeIf { it != fallbackSrc },
308
- url = null,
345
+ path = null,
309
346
  src = fallbackSrc,
310
347
  artwork = artworkOf(metadata.artworkUri?.toString()),
311
348
  artworkSource = null,
312
349
  request = null,
313
- artworkCarPlayTinted = null,
314
350
  title = metadata.title?.toString() ?: mediaId,
315
351
  subtitle = metadata.artist?.toString(),
316
352
  artist = null,
317
- albumUrl = null,
353
+ albumPath = null,
318
354
  album = metadata.albumTitle?.toString(),
319
355
  description = metadata.description?.toString(),
320
356
  genre = metadata.genre?.toString(),
321
357
  duration = null,
322
358
  style = null,
323
- childrenStyle = null,
359
+ disabled = null,
324
360
  favorited = null,
325
- groupTitle = null,
326
361
  live = null,
327
- imageRow = null,
362
+ icyCharset = null,
328
363
  )
329
364
  )
330
365
  }
@@ -362,17 +397,19 @@ class BrowserManager {
362
397
  if (searchQuery != null) {
363
398
  Timber.d("Handling search playback request for query: $searchQuery")
364
399
 
365
- // Execute search (will hit cache if already performed)
400
+ // Execute search (will hit cache if already performed). Disabled
401
+ // tracks never play, so they never enter a search-built queue.
366
402
  val searchResults = search(searchQuery)
367
- val searchTracks = searchResults.children
403
+ val searchTracks =
404
+ searchResults.flattenedChildren?.filterNot { it.disabled == true }?.toTypedArray()
368
405
 
369
406
  if (searchTracks != null && searchTracks.isNotEmpty()) {
370
407
  // Find the selected track in search results (mediaId is the stable id
371
- // when the track has one, else url/src — see TrackFactory)
408
+ // when the track has one, else path/src — see TrackFactory)
372
409
  val mediaId = mediaItem.mediaId
373
410
  val selectedIndex =
374
411
  searchTracks.indexOfFirst { track ->
375
- track.id == mediaId || track.url == mediaId || track.src == mediaId
412
+ track.id == mediaId || track.path == mediaId || track.src == mediaId
376
413
  }
377
414
 
378
415
  if (selectedIndex >= 0) {
@@ -399,12 +436,12 @@ class BrowserManager {
399
436
  val mediaId = mediaItems[0].mediaId
400
437
  // A failed search match falls through to plain resolution, never to browse
401
438
  // expansion — the user asked for that one result, not a browsed list.
402
- val contextualUrl = if (searchQuery == null) contextualUrlFor(mediaId) else null
439
+ val contextualPath = if (searchQuery == null) contextualPathFor(mediaId) else null
403
440
 
404
- if (contextualUrl != null) {
405
- Timber.d("Attempting queue expansion for mediaId='$mediaId' via '$contextualUrl'")
441
+ if (contextualPath != null) {
442
+ Timber.d("Attempting queue expansion for mediaId='$mediaId' via '$contextualPath'")
406
443
 
407
- val expanded = expandQueueFromContextualUrl(contextualUrl)
444
+ val expanded = expandQueueFromContextualPath(contextualPath)
408
445
 
409
446
  if (expanded != null) {
410
447
  val (tracks, selectedIndex) = expanded
@@ -424,20 +461,56 @@ class BrowserManager {
424
461
  // No expansion - resolve from cache (with a minimal-track fallback for replayed mediaIds)
425
462
  val resolvedTracks = mediaItems.map { resolveMediaItemToTrack(it) }
426
463
 
464
+ // A disabled track is unavailable — it never plays, even by a stale or
465
+ // replayed mediaId (Track.disabled). Refusal fails the future: Media3
466
+ // surfaces an error to the controller and the current playback stays
467
+ // untouched (an empty item list would clear the live timeline instead).
468
+ //
469
+ // Media3's legacy path — how Android Auto and Assistant actually request
470
+ // playback (onPlayFromMediaId et al.) — passes C.INDEX_UNSET, meaning
471
+ // "the default item", i.e. the first. Normalize before the guard, or the
472
+ // refusal is dead on exactly the path that serves cars.
473
+ val effectiveStartIndex = if (startIndex == C.INDEX_UNSET) 0 else startIndex
474
+ val startTrack = resolvedTracks.getOrNull(effectiveStartIndex)
475
+ if (startTrack != null && startTrack.disabled == true) {
476
+ Timber.w("Refusing playback of disabled track: ${startTrack.title}")
477
+ throw IllegalStateException("Refusing playback of disabled track: ${startTrack.title}")
478
+ }
479
+ val playableTracks = resolvedTracks.filterNot { it.disabled == true }
480
+ if (playableTracks.isEmpty()) {
481
+ // Belt over the start-item guard: never hand Media3 an empty timeline.
482
+ Timber.w("Refusing playback: every requested track is disabled")
483
+ throw IllegalStateException("Refusing playback: every requested track is disabled")
484
+ }
485
+ // Positional, not indexOf: value equality would alias a duplicated track
486
+ // (the same station twice in a queue) to its first copy. An unset index
487
+ // stays unset — Media3's "default item" semantics pass through as they
488
+ // did before the filter existed.
489
+ val adjustedStartIndex =
490
+ when {
491
+ startIndex == C.INDEX_UNSET -> C.INDEX_UNSET
492
+ startTrack == null -> 0
493
+ else -> resolvedTracks.take(effectiveStartIndex).count { it.disabled != true }
494
+ }
495
+
427
496
  // Convert to Media3 MediaItems
428
- val resolvedMediaItems = resolvedTracks.map { track -> TrackFactory.toMedia3(track) }
497
+ val resolvedMediaItems = playableTracks.map { track -> TrackFactory.toMedia3(track) }
429
498
 
430
- return MediaSession.MediaItemsWithStartPosition(resolvedMediaItems, startIndex, startPositionMs)
499
+ return MediaSession.MediaItemsWithStartPosition(
500
+ resolvedMediaItems,
501
+ adjustedStartIndex,
502
+ startPositionMs,
503
+ )
431
504
  }
432
505
 
433
506
  /**
434
- * Validates that a track has either url or src for stable identification. Throws
507
+ * Validates that a track has either path or src for stable identification. Throws
435
508
  * IllegalStateException if validation fails.
436
509
  */
437
510
  private fun validateTrack(track: Track, context: String) {
438
- if (track.url == null && track.src == null) {
511
+ if (track.path == null && track.src == null) {
439
512
  throw IllegalStateException(
440
- "$context must have either 'url' or 'src' property for stable identification. Track: ${track.title}"
513
+ "$context must have either 'path' or 'src' property for stable identification. Track: ${track.title}"
441
514
  )
442
515
  }
443
516
  }
@@ -445,12 +518,12 @@ class BrowserManager {
445
518
  suspend fun resolve(path: String, useCache: Boolean = true): ResolvedTrack {
446
519
  Timber.d("=== RESOLVE: path='$path' (useCache=$useCache) ===")
447
520
 
448
- // Strip __trackId from contextual URLs (e.g., "/library/radio?__trackId=song.mp3" →
521
+ // Strip __trackId from contextual paths (e.g., "/library/radio?__trackId=song.mp3" →
449
522
  // "/library/radio")
450
- // This allows resolving the parent container for tracks referenced by contextual URL
523
+ // This allows resolving the parent container for tracks referenced by contextual path
451
524
  val normalizedPath = BrowserPathHelper.stripTrackId(path)
452
525
  if (normalizedPath != path) {
453
- Timber.d("Stripped __trackId from contextual URL: '$normalizedPath'")
526
+ Timber.d("Stripped __trackId from contextual path: '$normalizedPath'")
454
527
  }
455
528
 
456
529
  // Check content cache first
@@ -458,7 +531,7 @@ class BrowserManager {
458
531
  contentCache.get(normalizedPath)?.let { cached ->
459
532
  Timber.d("Content cache HIT for path='$normalizedPath'")
460
533
  // Re-key the track cache even on a hit: an id-keyed lookup (stable-id
461
- // mediaId → contextual url, see contextualUrlFor) must reflect the
534
+ // mediaId → contextual path, see contextualPathFor) must reflect the
462
535
  // most recently *browsed* container, which a cached re-display
463
536
  // otherwise wouldn't re-register.
464
537
  cacheChildren(cached)
@@ -484,11 +557,11 @@ class BrowserManager {
484
557
  * (e.g., via notifyContentChanged).
485
558
  *
486
559
  * @param path The container path to invalidate (e.g., "/library/radio")
487
- * @throws IllegalArgumentException if passed a contextual URL (contains __trackId)
560
+ * @throws IllegalArgumentException if passed a contextual path (contains __trackId)
488
561
  */
489
562
  fun invalidateContentCache(path: String) {
490
563
  require(!BrowserPathHelper.isContextual(path)) {
491
- "invalidateContentCache() expects a container path, not a contextual URL: $path"
564
+ "invalidateContentCache() expects a container path, not a contextual path: $path"
492
565
  }
493
566
  contentCache.remove(path)
494
567
  Timber.d("Invalidated content cache for path='$path'")
@@ -497,6 +570,10 @@ class BrowserManager {
497
570
  /** Clears all cached content. */
498
571
  fun clearContentCache() {
499
572
  contentCache.evictAll()
573
+ // The search slot is content too: a locale switch or a re-auth changes titles, src, artwork
574
+ // and availability, and nothing else ever evicts it, so a repeated query returned
575
+ // pre-invalidation results forever. (Matches the Swift twin's clearContentCache.)
576
+ lastSearch = null
500
577
  // Bump the layer generation so request/browse resolver thunks are re-resolved on the next
501
578
  // request (invalidateAllContent → clearContentCache is the documented re-resolve trigger).
502
579
  layerGeneration += 1
@@ -525,81 +602,71 @@ class BrowserManager {
525
602
  effectiveArtworkConfig = config.artwork
526
603
  }
527
604
 
528
- // Transform children: generate contextual URLs and transform artwork URLs
529
- val transformedChildren =
530
- resolvedTrack.children?.let { children ->
531
- coroutineScope {
532
- children
533
- .mapIndexed { index, track ->
605
+ // Normalize to the canonical sectioned shape (children sugar → one untitled
606
+ // section — ADR 0010), then generate contextual paths and transform artwork
607
+ // URLs. The output never carries `children`.
608
+ val sections = resolvedTrack.normalizedSections ?: return resolvedTrack
609
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each level is
610
+ // still attributable. Every page passes here whatever its source, and only on a
611
+ // cache miss. No-op outside debug builds.
612
+ InertStyleDiagnostic.warn(path, resolvedTrack.style, sections)
613
+ // The contextual index is the flat page position — children concatenated in
614
+ // section order (ADR 0009/0010).
615
+ var flatIndex = 0
616
+ // One scope for the whole page: every child's transform (which can suspend
617
+ // on artwork resolution) launches up front across all sections, then the
618
+ // sections reassemble — a slow section doesn't serialize the ones after it.
619
+ val transformedSections = coroutineScope {
620
+ sections
621
+ .map { section ->
622
+ val base = flatIndex
623
+ flatIndex += section.children.size
624
+ section to
625
+ section.children.mapIndexed { offset, track ->
534
626
  async {
535
- // Validate that track has stable identifier. A track carrying an
536
- // imageRow is exempt: a url-less row is a pure preview — never
537
- // selected, navigated to, or cached (its items carry their own
538
- // identity; on Android Auto it expands into them).
539
- if (track.imageRow.isNullOrEmpty()) validateTrack(track, "Child track")
627
+ val index = base + offset
628
+ // Validate that track has stable identifier.
629
+ validateTrack(track, "Child track")
540
630
 
541
631
  var transformedTrack = track
542
632
 
543
- // Generate contextual URLs for playable tracks
633
+ // Generate contextual paths for playable tracks
544
634
  // Always regenerate to reflect the current browsing context, not the original
545
635
  // context
546
636
  // (e.g., a track favorited from an album should use /favorites context when browsed
547
637
  // there)
548
- if (track.src != null) {
549
- val contextualUrl = BrowserPathHelper.build(path, track.src)
550
- transformedTrack = transformedTrack.copy(url = contextualUrl)
638
+ // The flat page position rides along as a duplicate-identity tie-breaker.
639
+ val trackIdentity = track.identity
640
+ if (track.src != null && trackIdentity != null) {
641
+ val contextualPath = BrowserPathHelper.build(path, trackIdentity, index)
642
+ transformedTrack = transformedTrack.copy(path = contextualPath)
551
643
 
552
644
  Timber.d(
553
- "[$path] Child[$index] '${track.title}': Playable, contextualUrl=$contextualUrl (src=${track.src})"
645
+ "[$path] Child[$index] '${track.title}': Playable, contextualPath=$contextualPath (identity=$trackIdentity)"
554
646
  )
555
647
  } else {
556
648
  Timber.d(
557
- "[$path] Child[$index] '${track.title}': Browsable with url=${track.url}"
649
+ "[$path] Child[$index] '${track.title}': Browsable with path=${track.path}"
558
650
  )
559
651
  }
560
652
 
561
- // Playable image-row items get contextual URLs like any list
562
- // row, so a tile tap expands into its section's queue instead
563
- // of a queue of one (ADR 0006).
564
- transformedTrack.imageRow?.let { items ->
565
- transformedTrack =
566
- transformedTrack.copy(
567
- imageRow =
568
- items
569
- .map { item ->
570
- if (item.url == null && item.src != null) {
571
- item.copy(url = BrowserPathHelper.build(path, item.src))
572
- } else {
573
- item
574
- }
575
- }
576
- .toTypedArray()
577
- )
578
- }
579
-
580
653
  // Transform artwork URL. At browse-time there is no display size info.
581
- transformedTrack =
582
- transformArtworkUrl(
583
- transformedTrack,
584
- effectiveArtworkConfig,
585
- path,
586
- index,
587
- ImageContext(null, null),
588
- )
589
-
590
- transformedTrack
654
+ transformArtworkUrl(
655
+ transformedTrack,
656
+ effectiveArtworkConfig,
657
+ path,
658
+ index,
659
+ ImageContext(null, null),
660
+ )
591
661
  }
592
662
  }
593
- .awaitAll()
594
663
  }
595
- }
596
-
597
- // Return resolved track with transformed children
598
- return if (transformedChildren != null) {
599
- resolvedTrack.copy(children = transformedChildren.toTypedArray())
600
- } else {
601
- resolvedTrack
664
+ .map { (section, transformedChildren) ->
665
+ section.copy(children = transformedChildren.awaitAll().toTypedArray())
666
+ }
602
667
  }
668
+
669
+ return resolvedTrack.copy(sections = transformedSections.toTypedArray(), children = null)
603
670
  }
604
671
 
605
672
  /**
@@ -658,55 +725,68 @@ class BrowserManager {
658
725
  }
659
726
 
660
727
  /**
661
- * Expands a contextual URL into a queue of playable tracks.
728
+ * Expands a contextual path into a queue of playable tracks.
662
729
  *
663
730
  * Used when navigating to a track to load it with its full album/playlist context. Returns only
664
731
  * the selected track if singleTrack is true.
665
732
  *
666
- * @param contextualUrl The contextual URL (e.g., "/album?__trackId=song.mp3")
733
+ * @param contextualPath The contextual path (e.g., "/album?__trackId=song.mp3")
667
734
  * @return Pair of (tracks array, selected track index), or null if expansion fails
668
735
  */
669
- suspend fun expandQueueFromContextualUrl(contextualUrl: String): Pair<Array<Track>, Int>? {
670
- val trackId = BrowserPathHelper.extractTrackId(contextualUrl) ?: return null
736
+ suspend fun expandQueueFromContextualPath(contextualPath: String): Pair<Array<Track>, Int>? {
737
+ val trackId = BrowserPathHelper.extractTrackId(contextualPath) ?: return null
671
738
 
672
- Timber.d("Expanding queue from contextual URL: $contextualUrl (trackId=$trackId)")
739
+ Timber.d("Expanding queue from contextual path: $contextualPath (trackId=$trackId)")
673
740
 
674
741
  try {
675
742
  // Resolve the parent container to get all siblings
676
- val parentPath = BrowserPathHelper.stripTrackId(contextualUrl)
743
+ val parentPath = BrowserPathHelper.stripTrackId(contextualPath)
677
744
  val parentResolvedTrack = resolve(parentPath)
678
- val children = parentResolvedTrack.children
745
+ val sections = parentResolvedTrack.normalizedSections
679
746
 
680
- if (children.isNullOrEmpty()) {
747
+ if (sections.isNullOrEmpty()) {
681
748
  Timber.w("Parent has no children, cannot expand queue")
682
749
  return null
683
750
  }
684
751
 
685
- // Queue scope is the tapped section, not the whole page (ADR 0006). An
686
- // id that no longer appears on the page aborts the expansion — the
687
- // caller falls back to the stored single track; silently queueing the
688
- // changed list would resume the wrong station.
689
- val sectionTracks =
690
- when (val section = SectionScope.section(children.toList(), trackId)) {
691
- is SectionScope.Section.ImageRow ->
692
- with(TrackFactory) { section.items.map { it.toTrack(groupTitle = null) } }
693
- is SectionScope.Section.Run -> section.tracks
694
- null -> return null
695
- }
696
-
697
- // Filter to only playable tracks (tracks with src)
698
- val playableTracks = sectionTracks.filter { track -> track.src != null }
752
+ // Queue scope is the tapped section, not the whole page (ADR 0006). The
753
+ // stamped flat index pins which section (and which copy) was tapped when
754
+ // the same identity appears more than once; it is only a tie-breaker — a
755
+ // stale index falls back to the first identity match. An id that no
756
+ // longer appears on the page at all aborts the expansion — the caller
757
+ // falls back to the stored single track; silently queueing the changed
758
+ // list would resume the wrong station.
759
+ val tappedIndex = BrowserPathHelper.extractIndex(contextualPath)
760
+ val scoped = SectionScope.scoped(sections, trackId, tappedIndex) ?: return null
761
+ val sectionTracks = scoped.section.children.toList()
762
+ val tappedOffset = scoped.tappedOffset
763
+
764
+ // Filter to only playable tracks (tracks with src). A disabled track is
765
+ // unavailable — queue expansion excludes it, so auto-advance never meets
766
+ // one.
767
+ val playableTracks =
768
+ sectionTracks.filter { track -> track.src != null && track.disabled != true }
699
769
 
700
770
  if (playableTracks.isEmpty()) {
701
771
  Timber.w("Parent has no playable tracks, cannot expand queue")
702
772
  return null
703
773
  }
704
774
 
705
- // Find the index of the selected track in the playable tracks array
706
- val selectedIndex = playableTracks.indexOfFirst { track -> track.src == trackId }
775
+ // Find the index of the selected track in the playable tracks array:
776
+ // the pinned copy when the stamp survived, else the first identity match
777
+ val selectedIndex =
778
+ if (
779
+ tappedOffset != null &&
780
+ sectionTracks[tappedOffset].src != null &&
781
+ sectionTracks[tappedOffset].disabled != true
782
+ ) {
783
+ sectionTracks.take(tappedOffset + 1).count { it.src != null && it.disabled != true } - 1
784
+ } else {
785
+ playableTracks.indexOfFirst { track -> track.identity == trackId }
786
+ }
707
787
 
708
788
  if (selectedIndex < 0) {
709
- Timber.w("Track with src='$trackId' not found in playable children")
789
+ Timber.w("Track with identity='$trackId' not found in playable children")
710
790
  return null
711
791
  }
712
792
 
@@ -717,11 +797,11 @@ class BrowserManager {
717
797
  }
718
798
 
719
799
  Timber.d(
720
- "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${children.size} total), starting at index $selectedIndex"
800
+ "singleTrack=false - returning ${playableTracks.size} playable tracks (from ${sectionTracks.size} in section), starting at index $selectedIndex"
721
801
  )
722
802
  return Pair(playableTracks.toTypedArray(), selectedIndex)
723
803
  } catch (e: Exception) {
724
- Timber.e(e, "Error expanding queue from contextual URL: $contextualUrl")
804
+ Timber.e(e, "Error expanding queue from contextual path: $contextualPath")
725
805
  return null
726
806
  }
727
807
  }
@@ -797,6 +877,8 @@ class BrowserManager {
797
877
  *
798
878
  * @param query The search query string
799
879
  * @return Array of playable tracks, or null if no results or search not configured
880
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
881
+ * never "couldn't ask"
800
882
  */
801
883
  suspend fun searchPlayable(query: String): Array<Track>? {
802
884
  return searchPlayable(
@@ -820,10 +902,15 @@ class BrowserManager {
820
902
  *
821
903
  * @param params The structured search parameters
822
904
  * @return Array of playable tracks, or null if no results or search not configured
905
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
906
+ * never "couldn't ask"
823
907
  */
824
908
  suspend fun searchPlayable(params: SearchParams): Array<Track>? {
825
909
  val searchResults = search(params)
826
- val tracks = searchResults.children
910
+ // Voice search never matches a disabled track (Track.disabled) — filtered
911
+ // before the first result is picked, so an unavailable one can't capture
912
+ // the play.
913
+ val tracks = searchResults.flattenedChildren?.filter { it.disabled != true }?.toTypedArray()
827
914
 
828
915
  if (tracks.isNullOrEmpty()) {
829
916
  return null
@@ -833,13 +920,13 @@ class BrowserManager {
833
920
 
834
921
  // Check if result is browsable-only (container/route) vs playable
835
922
  // If it's browsable but also playable (has src or playable=true), treat it as playable
836
- val firstResultUrl = firstResult.url
923
+ val firstResultPath = firstResult.path
837
924
  val tracksToFilter =
838
- if (firstResult.src == null && firstResultUrl != null) {
839
- Timber.d("First search result is browsable-only, resolving: $firstResultUrl")
840
- val resolvedTrack = resolve(firstResultUrl)
841
- resolvedTrack.children
842
- ?.filter { it.src != null }
925
+ if (firstResult.src == null && firstResultPath != null) {
926
+ Timber.d("First search result is browsable-only, resolving: $firstResultPath")
927
+ val resolvedTrack = resolve(firstResultPath)
928
+ resolvedTrack.flattenedChildren
929
+ ?.filter { it.src != null && it.disabled != true }
843
930
  ?.takeIf { it.isNotEmpty() }
844
931
  ?.toTypedArray() ?: tracks
845
932
  } else {
@@ -854,6 +941,8 @@ class BrowserManager {
854
941
  *
855
942
  * @param query The search query string
856
943
  * @return ResolvedTrack containing search results as children
944
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
945
+ * never "couldn't ask"
857
946
  */
858
947
  suspend fun search(query: String): ResolvedTrack {
859
948
  return search(
@@ -877,6 +966,8 @@ class BrowserManager {
877
966
  *
878
967
  * @param params The structured search parameters
879
968
  * @return ResolvedTrack containing search results as children
969
+ * @throws Exception when the search itself fails — a zero-result page means "found nothing",
970
+ * never "couldn't ask"
880
971
  */
881
972
  suspend fun search(params: SearchParams): ResolvedTrack {
882
973
  Timber.d("Executing fresh search for: ${params.query} (mode=${params.mode})")
@@ -884,34 +975,35 @@ class BrowserManager {
884
975
  val searchPath = BrowserPathHelper.createSearchPath(params.query)
885
976
 
886
977
  try {
887
- // Execute search. Drop results without a stable identifier (url or src): search results come
978
+ // Execute search. Drop results without a stable identifier (path or src): search results come
888
979
  // from server/JS data that doesn't pass through validateTrack like browse children do, and
889
980
  // downstream conversion (TrackFactory.toMedia3) and browsable-result resolution require one.
890
981
  val searchResults =
891
982
  resolveSearch(params)
892
983
  .filter { track ->
893
- val valid = track.url != null || track.src != null
984
+ val valid = track.path != null || track.src != null
894
985
  if (!valid) {
895
- Timber.w("Dropping search result without url or src: '${track.title}'")
986
+ Timber.w("Dropping search result without path or src: '${track.title}'")
896
987
  }
897
988
  valid
898
989
  }
899
990
  .toTypedArray()
900
991
 
901
- // Create ResolvedTrack
992
+ // Search results are a flat list; as a resolved page they are one
993
+ // untitled section (ADR 0010).
902
994
  val searchResolvedTrack =
903
995
  ResolvedTrack(
904
996
  id = null,
905
- url = searchPath,
997
+ path = searchPath,
906
998
  title = "Search: ${params.query}",
907
- children = searchResults,
999
+ sections = arrayOf(untitledSection(searchResults)),
1000
+ children = null,
908
1001
  carPlaySiriListButton = null,
909
1002
  artwork = null,
910
1003
  artworkSource = null,
911
1004
  request = null,
912
- artworkCarPlayTinted = null,
913
1005
  artist = null,
914
- albumUrl = null,
1006
+ albumPath = null,
915
1007
  description = null,
916
1008
  subtitle = null,
917
1009
  album = null,
@@ -919,11 +1011,10 @@ class BrowserManager {
919
1011
  duration = null,
920
1012
  src = null,
921
1013
  style = null,
922
- childrenStyle = null,
1014
+ disabled = null,
923
1015
  favorited = null,
924
- groupTitle = null,
925
1016
  live = null,
926
- imageRow = null,
1017
+ icyCharset = null,
927
1018
  )
928
1019
 
929
1020
  // Cache search results for getCachedSearchResults()
@@ -937,37 +1028,10 @@ class BrowserManager {
937
1028
 
938
1029
  return searchResolvedTrack
939
1030
  } catch (e: Exception) {
1031
+ // Propagates, like the iOS `search`: a caller that cannot tell a failed search from one
1032
+ // that found nothing renders a server error as "No results found".
940
1033
  Timber.e(e, "Error during search for query: ${params.query}")
941
-
942
- // Return empty search result on error
943
- val emptySearchResult =
944
- ResolvedTrack(
945
- id = null,
946
- url = searchPath,
947
- title = "Search: ${params.query}",
948
- children = emptyArray(),
949
- carPlaySiriListButton = null,
950
- artwork = null,
951
- artworkSource = null,
952
- request = null,
953
- artworkCarPlayTinted = null,
954
- artist = null,
955
- albumUrl = null,
956
- description = null,
957
- subtitle = null,
958
- album = null,
959
- genre = null,
960
- duration = null,
961
- src = null,
962
- style = null,
963
- childrenStyle = null,
964
- favorited = null,
965
- groupTitle = null,
966
- live = null,
967
- imageRow = null,
968
- )
969
-
970
- return emptySearchResult
1034
+ throw e
971
1035
  }
972
1036
  }
973
1037
 
@@ -1041,7 +1105,7 @@ class BrowserManager {
1041
1105
  // Validate tabs have stable identifiers
1042
1106
  tabs.forEachIndexed { index, tab ->
1043
1107
  validateTrack(tab, "Tab")
1044
- Timber.d("[TABS] Tab[$index] '${tab.title}': url=${tab.url}")
1108
+ Timber.d("[TABS] Tab[$index] '${tab.title}': path=${tab.path}")
1045
1109
  }
1046
1110
 
1047
1111
  this.tabs = tabs
@@ -1057,6 +1121,14 @@ class BrowserManager {
1057
1121
 
1058
1122
  /** Internal path used for search */
1059
1123
  internal const val SEARCH_ROUTE_PATH = "__search__"
1124
+
1125
+ /**
1126
+ * Title of the appended "view more" row when the consumer sets no
1127
+ * [BrowserConfig.viewMoreTitle]. English in code, like [defaultFormattedError]'s error copy — a
1128
+ * default that renders beats a section whose `path` is unreachable, and it still resolves when
1129
+ * the JS runtime is gone.
1130
+ */
1131
+ internal const val DEFAULT_VIEW_MORE_TITLE = "View more"
1060
1132
  }
1061
1133
 
1062
1134
  /**
@@ -1179,7 +1251,8 @@ class BrowserManager {
1179
1251
  Timber.d("Resolving tabs via route entry")
1180
1252
  val resolvedTrack = resolveRouteEntry(tabsEntry, TABS_ROUTE_PATH, emptyMap())
1181
1253
 
1182
- return resolvedTrack.children ?: emptyArray()
1254
+ // Tabs are a flat list, not a page — a sectioned tabs source flattens.
1255
+ return resolvedTrack.flattenedChildren?.toTypedArray() ?: emptyArray()
1183
1256
  }
1184
1257
 
1185
1258
  /**
@@ -1231,6 +1304,33 @@ class BrowserManager {
1231
1304
  return resolvedRequestLayer
1232
1305
  }
1233
1306
 
1307
+ /**
1308
+ * The title for the "view more" row appended to a pathed section on a surface whose header cannot
1309
+ * be tapped (Android Auto).
1310
+ *
1311
+ * Resolved from [BrowserConfig.viewMoreTitle] once per content generation — the same generation
1312
+ * the resolver layers use, so `invalidateAllContent()` re-reads it and an app that switched
1313
+ * language mid-drive gets the new copy without reconfiguring. Falls back to
1314
+ * [DEFAULT_VIEW_MORE_TITLE] when unset, blank, or throwing: a row with the default copy still
1315
+ * reaches the section's page, an untitled one does not.
1316
+ */
1317
+ internal suspend fun viewMoreTitle(): String {
1318
+ val generation = layerGeneration
1319
+ if (resolvedViewMoreTitleGeneration == generation) return resolvedViewMoreTitle
1320
+ val title =
1321
+ try {
1322
+ config.viewMoreTitle?.invoke()?.await()?.takeIf { it.isNotBlank() }
1323
+ } catch (e: Exception) {
1324
+ Timber.e(e, "viewMoreTitle callback failed; falling back to the default")
1325
+ null
1326
+ } ?: DEFAULT_VIEW_MORE_TITLE
1327
+ // A newer generation started while awaiting — use this result, but don't cache it as current.
1328
+ if (generation != layerGeneration) return title
1329
+ resolvedViewMoreTitle = title
1330
+ resolvedViewMoreTitleGeneration = generation
1331
+ return title
1332
+ }
1333
+
1234
1334
  /**
1235
1335
  * Builds the HTTP request for an API-backed path by layering request (shared) → kind
1236
1336
  * (browse/search) → route configs. Each layer's transform receives the previous layer's output; a
@@ -1429,6 +1529,8 @@ data class BrowserConfig(
1429
1529
  // Behavior
1430
1530
  val singleTrack: Boolean = false,
1431
1531
  val androidControllerOfflineError: Boolean = true,
1532
+ // Titles the "view more" row appended to a pathed section. See BrowserManager.viewMoreTitle.
1533
+ val viewMoreTitle: (() -> Promise<String>)? = null,
1432
1534
  ) {
1433
1535
  /** Returns true if search functionality is configured (either callback or config). */
1434
1536
  val hasSearch: Boolean