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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/README.md +4 -4
  2. package/android/build.gradle +6 -5
  3. package/android/gradle.properties +1 -1
  4. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +110 -25
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +254 -188
  6. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +23 -3
  7. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +77 -58
  8. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +68 -26
  9. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +49 -0
  10. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +9 -7
  11. package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
  12. package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
  13. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +120 -67
  14. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -24
  15. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +93 -73
  16. package/android/src/main/java/com/audiobrowser/player/Player.kt +48 -30
  17. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  18. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +46 -5
  19. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +86 -27
  20. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  21. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +20 -6
  22. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +249 -86
  23. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +44 -40
  24. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  25. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +35 -0
  26. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +45 -53
  27. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +69 -30
  28. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  29. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
  30. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +345 -8
  31. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  32. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +1 -1
  33. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +91 -0
  34. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -53
  35. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +87 -0
  36. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  37. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  38. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +1 -2
  39. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  40. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  41. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +152 -8
  42. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +218 -10
  43. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  44. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  45. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +161 -0
  46. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  47. package/ios/Browser/BrowserConfig.swift +102 -5
  48. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  49. package/ios/Browser/BrowserManager.swift +131 -143
  50. package/ios/Browser/BrowserPathHelper.swift +44 -44
  51. package/ios/Browser/JsonModels.swift +187 -106
  52. package/ios/Browser/SearchDrillIn.swift +6 -6
  53. package/ios/Browser/SectionPresentation.swift +34 -0
  54. package/ios/Browser/SectionScope.swift +58 -24
  55. package/ios/Browser/StyleResolver.swift +47 -0
  56. package/ios/Browser/TabBarEntries.swift +2 -2
  57. package/ios/Browser/TrackIdTemplate.swift +37 -0
  58. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  59. package/ios/CarPlay/CarPlayController.swift +105 -67
  60. package/ios/CarPlay/CarPlayImageLoader.swift +38 -18
  61. package/ios/CarPlay/CarPlayListItemFactory.swift +217 -84
  62. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  63. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  64. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  65. package/ios/Extension/ResolvedTrack+Copying.swift +11 -15
  66. package/ios/Extension/Section+Copying.swift +31 -0
  67. package/ios/Extension/Track+Copying.swift +6 -12
  68. package/ios/Extension/Track+Identity.swift +18 -0
  69. package/ios/HybridAudioBrowser.swift +113 -35
  70. package/ios/Model/NitroTypeStubs.swift +64 -23
  71. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  72. package/ios/Player/NowPlayingUpdater.swift +1 -1
  73. package/ios/Player/PlaybackCoordinator.swift +2 -2
  74. package/ios/Player/RetryManager.swift +4 -3
  75. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  76. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  77. package/ios/Tests/JsonModelsTests.swift +75 -1
  78. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  79. package/ios/Tests/QueueManagerTests.swift +4 -4
  80. package/ios/Tests/ResolvedTrackCopyingTests.swift +37 -35
  81. package/ios/Tests/SearchDrillInTests.swift +16 -16
  82. package/ios/Tests/SectionPresentationTests.swift +61 -0
  83. package/ios/Tests/SectionScopeTests.swift +87 -55
  84. package/ios/Tests/StyleResolverTests.swift +67 -0
  85. package/ios/Tests/TabBarEntriesTests.swift +18 -18
  86. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  87. package/ios/Tests/TrackIdentityTests.swift +20 -0
  88. package/ios/Tests/TrackSelectorTests.swift +168 -33
  89. package/ios/TrackSelector.swift +45 -30
  90. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  91. package/lib/commonjs/AudioBrowser.js +11 -0
  92. package/lib/commonjs/AudioBrowser.js.map +1 -1
  93. package/lib/commonjs/features/browser-config.js +11 -8
  94. package/lib/commonjs/features/browser-config.js.map +1 -1
  95. package/lib/commonjs/features/browser.js +2 -2
  96. package/lib/commonjs/features/favorites.js +13 -10
  97. package/lib/commonjs/features/favorites.js.map +1 -1
  98. package/lib/commonjs/features/playback/controls.js +4 -2
  99. package/lib/commonjs/features/playback/controls.js.map +1 -1
  100. package/lib/commonjs/features/playback/state.js +2 -3
  101. package/lib/commonjs/features/playback/state.js.map +1 -1
  102. package/lib/commonjs/features/player/options.js.map +1 -1
  103. package/lib/commonjs/features/player/setup.js +3 -1
  104. package/lib/commonjs/features/player/setup.js.map +1 -1
  105. package/lib/commonjs/features/player/validateOptions.js +2 -0
  106. package/lib/commonjs/features/player/validateOptions.js.map +1 -1
  107. package/lib/commonjs/features/queue/activeTrack.js +14 -3
  108. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  109. package/lib/commonjs/features/queue/queue.js +11 -4
  110. package/lib/commonjs/features/queue/queue.js.map +1 -1
  111. package/lib/commonjs/utils/LazyNativeEmitter.js +21 -10
  112. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  113. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  114. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  115. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  116. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  117. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  118. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  119. package/lib/commonjs/web/NativeAudioBrowser.js +32 -24
  120. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  121. package/lib/commonjs/web/browser/BrowserManager.js +144 -87
  122. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  123. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  124. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  125. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  126. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  127. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  128. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  129. package/lib/commonjs/web/browser/sections.js +89 -0
  130. package/lib/commonjs/web/browser/sections.js.map +1 -0
  131. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  132. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  133. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  134. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  135. package/lib/module/AudioBrowser.js +1 -0
  136. package/lib/module/AudioBrowser.js.map +1 -1
  137. package/lib/module/features/browser-config.js +11 -8
  138. package/lib/module/features/browser-config.js.map +1 -1
  139. package/lib/module/features/browser.js +2 -2
  140. package/lib/module/features/favorites.js +13 -10
  141. package/lib/module/features/favorites.js.map +1 -1
  142. package/lib/module/features/playback/controls.js +4 -2
  143. package/lib/module/features/playback/controls.js.map +1 -1
  144. package/lib/module/features/playback/state.js +2 -3
  145. package/lib/module/features/playback/state.js.map +1 -1
  146. package/lib/module/features/player/options.js.map +1 -1
  147. package/lib/module/features/player/setup.js +3 -1
  148. package/lib/module/features/player/setup.js.map +1 -1
  149. package/lib/module/features/player/validateOptions.js +2 -0
  150. package/lib/module/features/player/validateOptions.js.map +1 -1
  151. package/lib/module/features/queue/activeTrack.js +14 -3
  152. package/lib/module/features/queue/activeTrack.js.map +1 -1
  153. package/lib/module/features/queue/queue.js +11 -4
  154. package/lib/module/features/queue/queue.js.map +1 -1
  155. package/lib/module/utils/LazyNativeEmitter.js +21 -10
  156. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  157. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  158. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  159. package/lib/module/utils/getTrackIdentity.js +20 -0
  160. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  161. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  162. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  163. package/lib/module/web/NativeAudioBrowser.js +32 -24
  164. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  165. package/lib/module/web/browser/BrowserManager.js +144 -87
  166. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  167. package/lib/module/web/browser/FavoriteManager.js +31 -21
  168. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  169. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  170. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  171. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  172. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  173. package/lib/module/web/browser/sections.js +83 -0
  174. package/lib/module/web/browser/sections.js.map +1 -0
  175. package/lib/module/web/player/NowPlayingManager.js +4 -3
  176. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  177. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  178. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  179. package/lib/typescript/src/AudioBrowser.d.ts +1 -0
  180. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  181. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  182. package/lib/typescript/src/features/browser.d.ts +2 -2
  183. package/lib/typescript/src/features/favorites.d.ts +13 -10
  184. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  185. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  186. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  187. package/lib/typescript/src/features/playback/state.d.ts +2 -3
  188. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  189. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  190. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  191. package/lib/typescript/src/features/player/options.d.ts +21 -19
  192. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  193. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  194. package/lib/typescript/src/features/player/validateOptions.d.ts +1 -8
  195. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -1
  196. package/lib/typescript/src/features/queue/activeTrack.d.ts +8 -1
  197. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  198. package/lib/typescript/src/features/queue/queue.d.ts +5 -2
  199. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  200. package/lib/typescript/src/types/browser-native.d.ts +2 -2
  201. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  202. package/lib/typescript/src/types/browser-nodes.d.ts +260 -127
  203. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  204. package/lib/typescript/src/types/browser.d.ts +33 -53
  205. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  206. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +16 -17
  207. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  208. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  209. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  210. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  211. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  212. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  213. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  214. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +0 -1
  215. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  216. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -3
  217. package/nitrogen/generated/android/c++/JBrowseResult.hpp +10 -4
  218. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  219. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -3
  220. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -3
  221. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -3
  222. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -3
  223. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -3
  224. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -3
  225. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -2
  226. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -2
  227. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +10 -4
  228. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -3
  229. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +2 -5
  230. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -3
  231. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +9 -3
  232. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -2
  233. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +21 -14
  234. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  235. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -3
  236. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +16 -10
  237. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +8 -2
  238. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +6 -7
  239. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +2 -5
  240. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  241. package/nitrogen/generated/android/c++/JOptions.hpp +2 -5
  242. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -3
  243. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  244. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +49 -51
  245. package/nitrogen/generated/android/c++/JSection.hpp +114 -0
  246. package/nitrogen/generated/android/c++/JSectionStyle.hpp +69 -0
  247. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  248. package/nitrogen/generated/android/c++/JTrack.hpp +19 -48
  249. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -2
  250. package/nitrogen/generated/android/c++/JTrackStyle.hpp +25 -18
  251. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +10 -4
  252. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  253. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  254. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  255. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +5 -5
  256. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  257. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  258. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -18
  259. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +50 -0
  260. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → SectionStyle.kt} +12 -6
  261. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  262. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -15
  263. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +23 -5
  264. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -82
  265. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +16 -10
  266. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +16 -10
  267. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  268. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  269. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  270. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +5 -5
  271. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  272. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  273. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +54 -83
  274. package/nitrogen/generated/ios/swift/Section.swift +100 -0
  275. package/nitrogen/generated/ios/swift/SectionStyle.swift +64 -0
  276. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  277. package/nitrogen/generated/ios/swift/Track.swift +20 -73
  278. package/nitrogen/generated/ios/swift/TrackStyle.swift +28 -22
  279. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  280. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  281. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  282. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -6
  283. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  284. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  285. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +30 -38
  286. package/nitrogen/generated/shared/c++/Section.hpp +106 -0
  287. package/nitrogen/generated/shared/c++/SectionStyle.hpp +96 -0
  288. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  289. package/nitrogen/generated/shared/c++/Track.hpp +15 -31
  290. package/nitrogen/generated/shared/c++/TrackStyle.hpp +50 -34
  291. package/package.json +3 -2
  292. package/src/AudioBrowser.ts +1 -0
  293. package/src/features/browser-config.test.ts +21 -6
  294. package/src/features/browser-config.ts +11 -8
  295. package/src/features/browser.test.ts +1 -1
  296. package/src/features/browser.ts +2 -2
  297. package/src/features/favorites.ts +13 -10
  298. package/src/features/hook-wiring.test.ts +63 -15
  299. package/src/features/playback/controls.ts +4 -2
  300. package/src/features/playback/state.ts +2 -3
  301. package/src/features/playback/trackPlaybackTime.ts +1 -1
  302. package/src/features/player/options.ts +27 -19
  303. package/src/features/player/setup.test.ts +12 -3
  304. package/src/features/player/setup.ts +3 -1
  305. package/src/features/player/validateOptions.ts +2 -0
  306. package/src/features/queue/activeTrack.ts +14 -3
  307. package/src/features/queue/queue.ts +11 -4
  308. package/src/nitro-stub-drift.test.ts +277 -0
  309. package/src/public-surface.test.ts +40 -0
  310. package/src/specs/audio-browser.nitro.ts +4 -3
  311. package/src/style-resolution.test.ts +139 -0
  312. package/src/types/browser-native.ts +2 -2
  313. package/src/types/browser-nodes.ts +268 -136
  314. package/src/types/browser.ts +34 -55
  315. package/src/utils/LazyNativeEmitter.ts +20 -10
  316. package/src/utils/NativeUpdatedValue.ts +29 -13
  317. package/src/utils/getTrackIdentity.ts +20 -0
  318. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  319. package/src/web/NativeAudioBrowser.ts +37 -22
  320. package/src/web/browser/BrowserManager.ts +176 -95
  321. package/src/web/browser/FavoriteManager.test.ts +84 -0
  322. package/src/web/browser/FavoriteManager.ts +28 -23
  323. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  324. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  325. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  326. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  327. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  328. package/src/web/browser/sections.test.ts +91 -0
  329. package/src/web/browser/sections.ts +72 -0
  330. package/src/web/player/NowPlayingManager.ts +4 -3
  331. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  332. package/src/web/util/BrowserPathHelper.ts +61 -13
  333. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  334. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  335. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  336. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  337. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  338. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  339. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  340. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  341. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  342. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  343. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  344. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  345. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
@@ -2,18 +2,26 @@ import type { NavigationErrorType } from '../../features'
2
2
  import type {
3
3
  Track,
4
4
  ResolvedTrack,
5
+ Section,
5
6
  TransformableRequestConfig
6
7
  } from '../../types'
7
8
  import type { NativeBrowserConfiguration } from '../../types/browser-native'
8
9
  import type { HttpClient } from '../http/HttpClient'
9
10
  import type { FavoriteManager } from './FavoriteManager'
10
11
  import type { NavigationErrorManager } from './NavigationErrorManager'
12
+ import { getTrackIdentity } from '../../utils/getTrackIdentity'
11
13
  import { assertedNotNullish } from '../../utils/validation'
12
14
  import { RequestConfigBuilder } from '../http/RequestConfigBuilder'
13
15
  import { SimpleRouter } from '../SimpleRouter'
14
16
  import { BrowserPathHelper } from '../util/BrowserPathHelper'
15
17
  import { assertBrowsePageShape } from './assertBrowsePageShape'
16
18
  import { parseSearchResponse } from './parseSearchResponse'
19
+ import {
20
+ flattenSections,
21
+ flattenedChildren,
22
+ normalizePage,
23
+ sectionContaining
24
+ } from './sections'
17
25
 
18
26
  /**
19
27
  * Manages browser navigation, route resolution, and content loading.
@@ -158,14 +166,13 @@ export class BrowserManager {
158
166
  // Determine initial path
159
167
  let initialPath = value.path
160
168
 
161
- // If no path specified, try to get first tab URL
169
+ // If no path specified, try to get first tab path
162
170
  if (!initialPath) {
163
171
  const tabsRoute = value.routes?.find((r) => r.path === '__tabs__')
164
- if (tabsRoute?.browseStatic?.children?.[0]?.url) {
165
- initialPath = tabsRoute.browseStatic.children[0].url
166
- } else {
167
- initialPath = '/'
168
- }
172
+ const staticTabs = tabsRoute?.browseStatic
173
+ ? flattenedChildren(tabsRoute.browseStatic)
174
+ : []
175
+ initialPath = staticTabs[0]?.path ?? '/'
169
176
  }
170
177
 
171
178
  if (initialPath) {
@@ -182,16 +189,16 @@ export class BrowserManager {
182
189
  }
183
190
 
184
191
  /**
185
- * Navigates to a track's URL.
192
+ * Navigates to a track's path.
186
193
  */
187
194
  async navigateTrack(track: Track): Promise<void> {
188
195
  this.navigationErrorManager.clearNavigationError()
189
- const url = track.url
190
- if (!url) {
191
- console.warn('Track has no URL to navigate to')
196
+ const path = track.path
197
+ if (!path) {
198
+ console.warn('Track has no path to navigate to')
192
199
  return
193
200
  }
194
- await this.navigate(url)
201
+ await this.navigate(path)
195
202
  }
196
203
 
197
204
  /**
@@ -247,23 +254,15 @@ export class BrowserManager {
247
254
  return
248
255
  }
249
256
 
250
- // Add contextual URLs to non-search content (matches Android behavior where
251
- // search() bypasses the resolve() contextual URL logic).
252
- // Shallow copy to avoid mutating the original config object (e.g., browseStatic
253
- // from routes), which would break search that reads from the same source.
254
- if (content?.children && !isSearchPath) {
257
+ // Resolution returns canonical sectioned pages; add contextual paths
258
+ // to non-search content (matches Android behavior where search()
259
+ // bypasses the resolve() contextual-path logic). Copies avoid mutating
260
+ // the original config object (e.g., browseStatic from routes), which
261
+ // would break search that reads from the same source.
262
+ if (content?.sections && !isSearchPath) {
255
263
  content = {
256
264
  ...content,
257
- children: content.children.map((track) => {
258
- // If track has src, always add/update contextual URL with current path context
259
- // This matches Android's BrowserManager.kt:436-441
260
- if (track.src) {
261
- const contextualUrl = `${path}?__trackId=${encodeURIComponent(track.src)}`
262
- return { ...track, url: contextualUrl }
263
- }
264
- // Return a shallow copy in attempt to avoid mutating original config objects
265
- return { ...track }
266
- })
265
+ sections: this.addContextualPaths(path, content.sections)
267
266
  }
268
267
  }
269
268
 
@@ -314,13 +313,40 @@ export class BrowserManager {
314
313
  }
315
314
  }
316
315
 
316
+ /**
317
+ * Stamps contextual paths onto a page's sections. A playable track gets a
318
+ * contextual path carrying its identity (id ?? src) so the queue can be
319
+ * re-expanded from it later, plus its flat page position (children
320
+ * concatenated in section order — ADR 0009/0010) as a duplicate-identity
321
+ * tie-breaker. Non-playable tracks are shallow-copied to avoid mutating
322
+ * original config objects (e.g., browseStatic from routes).
323
+ */
324
+ private addContextualPaths(path: string, sections: Section[]): Section[] {
325
+ let index = 0
326
+ return sections.map((section) => ({
327
+ ...section,
328
+ children: section.children.map((track) => {
329
+ const flatIndex = index++
330
+ const identity = getTrackIdentity(track)
331
+ if (track.src && identity) {
332
+ return {
333
+ ...track,
334
+ path: BrowserPathHelper.build(path, identity, flatIndex)
335
+ }
336
+ }
337
+ return { ...track }
338
+ })
339
+ }))
340
+ }
341
+
317
342
  /**
318
343
  * Resolves search content from a search path.
319
- * Returns a ResolvedTrack with raw children (no contextual URLs).
344
+ * Returns a canonical sectioned page — one untitled section of raw search
345
+ * results (no contextual URLs), like the native `makeSearchResult`.
320
346
  * Matches Android's BrowserManager.search() which bypasses resolve() entirely.
321
347
  *
322
348
  * @param searchPath The search path (format: /__search?q=query)
323
- * @returns ResolvedTrack containing search results as children
349
+ * @returns ResolvedTrack containing search results as one untitled section
324
350
  */
325
351
  private async resolveSearchContent(
326
352
  searchPath: string
@@ -366,13 +392,12 @@ export class BrowserManager {
366
392
  }
367
393
  }
368
394
 
369
- // Create ResolvedTrack with raw children
370
- // Search callbacks should return fresh tracks without contextual URLs (like Android)
371
- return {
372
- url: searchPath,
395
+ // Search callbacks should return fresh tracks without contextual paths (like Android)
396
+ return normalizePage({
397
+ path: searchPath,
373
398
  title: `Search: ${query}`,
374
399
  children: searchResults
375
- }
400
+ })
376
401
  }
377
402
 
378
403
  /**
@@ -400,35 +425,40 @@ export class BrowserManager {
400
425
  artworkConfig
401
426
  )
402
427
 
403
- // Transform children artwork
404
- let transformedChildren: Track[] | undefined
405
- if (content.children) {
406
- transformedChildren = await Promise.all(
407
- content.children.map(async (track) => {
408
- const artworkSource =
409
- await RequestConfigBuilder.resolveArtworkSourceAsync(
410
- track,
411
- requestConfig,
412
- artworkConfig
413
- )
414
- if (artworkSource && !track.artworkSource) {
415
- return { ...track, artworkSource }
416
- }
417
- return track
418
- })
428
+ // Transform children artwork, section by section
429
+ const transformTrack = async (track: Track): Promise<Track> => {
430
+ const artworkSource =
431
+ await RequestConfigBuilder.resolveArtworkSourceAsync(
432
+ track,
433
+ requestConfig,
434
+ artworkConfig
435
+ )
436
+ if (artworkSource && !track.artworkSource) {
437
+ return { ...track, artworkSource }
438
+ }
439
+ return track
440
+ }
441
+ let transformedSections: Section[] | undefined
442
+ if (content.sections) {
443
+ transformedSections = await Promise.all(
444
+ content.sections.map(async (section) => ({
445
+ ...section,
446
+ children: await Promise.all(section.children.map(transformTrack))
447
+ }))
419
448
  )
420
449
  }
421
450
 
422
451
  return {
423
452
  ...content,
424
453
  artworkSource: parentArtworkSource ?? content.artworkSource,
425
- children: transformedChildren ?? content.children
454
+ sections: transformedSections ?? content.sections
426
455
  }
427
456
  }
428
457
 
429
458
  /**
430
459
  * Resolves content from a route using browseCallback, browseStatic, or browseConfig.
431
- * Single source of truth for route resolution logic.
460
+ * Single source of truth for route resolution logic. Returns canonical
461
+ * sectioned pages (normalizePage) — consumers never see authored `children`.
432
462
  *
433
463
  * @param route The route configuration to resolve
434
464
  * @param path The path being navigated to
@@ -470,23 +500,26 @@ export class BrowserManager {
470
500
  )
471
501
  return undefined
472
502
  }
473
- return result
503
+ return normalizePage(result)
474
504
  }
475
505
 
476
- // Handle static ResolvedTrack route
506
+ // Handle static ResolvedTrack route (normalizePage also copies, so the
507
+ // config object itself is never handed out)
477
508
  if (route.browseStatic) {
478
- return route.browseStatic
509
+ return normalizePage(route.browseStatic)
479
510
  }
480
511
 
481
512
  // Handle request config-based route via the layered request → browse → route
482
513
  // chain (matches native's resolveRouteEntry → resolveFromConfig).
483
514
  if (route.browseConfig) {
484
515
  try {
485
- return await this.resolveFromConfig(
486
- this._configuration.browse,
487
- route.browseConfig,
488
- path,
489
- routeParams
516
+ return normalizePage(
517
+ await this.resolveFromConfig(
518
+ this._configuration.browse,
519
+ route.browseConfig,
520
+ path,
521
+ routeParams
522
+ )
490
523
  )
491
524
  } catch (error: unknown) {
492
525
  console.error(`Failed to resolve ${errorContext}:`, error)
@@ -592,6 +625,8 @@ export class BrowserManager {
592
625
 
593
626
  /**
594
627
  * Resolves content for a specific path using configured routes.
628
+ * Like the native resolve pipelines, the returned page is canonical
629
+ * sectioned shape — `children` sugar is normalized away at this seam.
595
630
  */
596
631
  private async resolveContent(
597
632
  path: string
@@ -658,11 +693,13 @@ export class BrowserManager {
658
693
  this._configuration.browseResolver
659
694
  ) {
660
695
  try {
661
- return await this.resolveFromConfig(
662
- this._configuration.browse,
663
- undefined,
664
- path,
665
- { path }
696
+ return normalizePage(
697
+ await this.resolveFromConfig(
698
+ this._configuration.browse,
699
+ undefined,
700
+ path,
701
+ { path }
702
+ )
666
703
  )
667
704
  } catch (error: unknown) {
668
705
  console.error('Failed to resolve default browse content:', error)
@@ -686,7 +723,8 @@ export class BrowserManager {
686
723
  }
687
724
 
688
725
  const result = await this.resolveRouteContent(tabsRoute, '/', {}, 'Tabs')
689
- const tabs = result?.children ?? []
726
+ // Tabs are a flat list, not a page — a sectioned tabs source flattens.
727
+ const tabs = result ? flattenSections(result.sections) : []
690
728
 
691
729
  // Transform artwork URLs on tabs via the async resolver so the shared
692
730
  // request layer (incl. its transform) applies, matching content/search.
@@ -724,44 +762,79 @@ export class BrowserManager {
724
762
  /**
725
763
  * Expands a queue from a contextual URL.
726
764
  * Resolves the parent container to get all siblings.
727
- * Matches Android's BrowserManager.expandQueueFromContextualUrl() behavior.
765
+ * Matches Android's BrowserManager.expandQueueFromContextualPath() behavior.
728
766
  *
729
- * @param contextualUrl The contextual URL (format: /path?__trackId=trackSrc)
767
+ * @param contextualPath The contextual path (format: /path?__trackId=trackSrc)
730
768
  * @returns Object with tracks and selectedIndex, or undefined if expansion fails
731
769
  */
732
- async expandQueueFromContextualUrl(
733
- contextualUrl: string
770
+ async expandQueueFromContextualPath(
771
+ contextualPath: string
734
772
  ): Promise<{ tracks: Track[]; selectedIndex: number } | undefined> {
735
- const trackId = BrowserPathHelper.extractTrackId(contextualUrl)
773
+ const trackId = BrowserPathHelper.extractTrackId(contextualPath)
736
774
  if (!trackId) return undefined
737
775
 
738
776
  try {
739
777
  // Resolve the parent container to get all siblings
740
- const parentPath = BrowserPathHelper.stripTrackId(contextualUrl)
778
+ const parentPath = BrowserPathHelper.stripTrackId(contextualPath)
741
779
  const parentResolvedTrack = await this.resolveContent(parentPath)
742
- const children = parentResolvedTrack?.children
780
+ const rawSections = parentResolvedTrack?.sections
743
781
 
744
- if (!children || children.length === 0) {
782
+ if (!rawSections || rawSections.every((s) => s.children.length === 0)) {
745
783
  console.warn('Parent has no children, cannot expand queue')
746
784
  return undefined
747
785
  }
748
786
 
749
- // Filter to only playable tracks (tracks with src)
750
- const playableTracks = children.filter((track) => track.src != null)
787
+ // Stamp contextual paths so queued tracks carry their own queue context
788
+ // (navigate() stamps the displayed page the same way).
789
+ const sections = this.addContextualPaths(parentPath, rawSections)
790
+
791
+ // Queue scope is the tapped section, not the whole page (ADR 0006).
792
+ // The stamped flat index pins which section (and which copy) was
793
+ // tapped when the same identity appears more than once; a stale index
794
+ // falls back to the first identity match. An identity that no longer
795
+ // appears on the page aborts the expansion — the caller falls back to
796
+ // the single track.
797
+ const tappedIndex = BrowserPathHelper.extractIndex(contextualPath)
798
+ const scoped = sectionContaining(sections, trackId, tappedIndex)
799
+ if (!scoped) {
800
+ console.warn(
801
+ `Track with identity='${trackId}' not found on parent page`
802
+ )
803
+ return undefined
804
+ }
805
+
806
+ // Filter to only playable tracks (tracks with src). A disabled track is
807
+ // unavailable — queue expansion excludes it, so auto-advance never
808
+ // meets one (Track.disabled).
809
+ const isQueueable = (track: Track) =>
810
+ track.src != null && track.disabled !== true
811
+ const playableTracks = scoped.tracks.filter(isQueueable)
751
812
 
752
813
  if (playableTracks.length === 0) {
753
- console.warn('Parent has no playable tracks, cannot expand queue')
814
+ console.warn('Section has no playable tracks, cannot expand queue')
754
815
  return undefined
755
816
  }
756
817
 
757
- // Find the index of the selected track in the playable tracks array
758
- const selectedIndex = playableTracks.findIndex(
759
- (track) => track.src === trackId
760
- )
818
+ // Selected position: the pinned copy when the stamp survived, else
819
+ // the first identity match.
820
+ let selectedIndex = -1
821
+ if (scoped.tappedOffset !== undefined) {
822
+ const tapped = scoped.tracks[scoped.tappedOffset]
823
+ if (tapped != null && isQueueable(tapped)) {
824
+ selectedIndex =
825
+ scoped.tracks.slice(0, scoped.tappedOffset + 1).filter(isQueueable)
826
+ .length - 1
827
+ }
828
+ }
829
+ if (selectedIndex < 0) {
830
+ selectedIndex = playableTracks.findIndex(
831
+ (track) => getTrackIdentity(track) === trackId
832
+ )
833
+ }
761
834
 
762
835
  if (selectedIndex < 0) {
763
836
  console.warn(
764
- `Track with src='${trackId}' not found in playable children`
837
+ `Track with identity='${trackId}' not found in playable children`
765
838
  )
766
839
  return undefined
767
840
  }
@@ -776,7 +849,7 @@ export class BrowserManager {
776
849
  return { tracks: playableTracks, selectedIndex }
777
850
  } catch (error) {
778
851
  console.error(
779
- `Error expanding queue from contextual URL: ${contextualUrl}`,
852
+ `Error expanding queue from contextual path: ${contextualPath}`,
780
853
  error
781
854
  )
782
855
  return undefined
@@ -800,23 +873,31 @@ export class BrowserManager {
800
873
  startPositionMs: number,
801
874
  searchQuery?: string
802
875
  ): Promise<{ tracks: Track[]; startIndex: number; startPositionMs: number }> {
803
- // Single track: check for search context or contextual URL
876
+ // Single track: check for search context or contextual path
804
877
  if (tracks.length === 1) {
805
878
  const track = assertedNotNullish(tracks[0])
806
- const trackUrl = track.url
879
+ const trackPath = track.path
807
880
 
808
881
  // If search query present, expand search results
809
882
  if (searchQuery) {
810
- // Execute search (will hit cache if already performed)
883
+ // Execute search (will hit cache if already performed). Disabled
884
+ // tracks never play, so they never enter a search-built queue
885
+ // (Track.disabled — mirrors both native platforms).
811
886
  const searchResults = await this.resolveSearchContent(
812
887
  BrowserPathHelper.createSearchPath(searchQuery)
813
888
  )
814
- const searchTracks = searchResults?.children
889
+ const searchTracks = flattenSections(searchResults?.sections).filter(
890
+ (t) => t.disabled !== true
891
+ )
815
892
 
816
- if (searchTracks && searchTracks.length > 0) {
817
- // Find the selected track in search results
893
+ if (searchTracks.length > 0) {
894
+ // Find the selected track in search results by path or identity
895
+ // (matches Android's three-way mediaId match)
896
+ const identity = getTrackIdentity(track)
818
897
  const selectedIdx = searchTracks.findIndex(
819
- (t) => t.url === trackUrl || t.src === track.src
898
+ (t) =>
899
+ t.path === trackPath ||
900
+ (identity !== undefined && getTrackIdentity(t) === identity)
820
901
  )
821
902
 
822
903
  if (selectedIdx >= 0) {
@@ -829,14 +910,14 @@ export class BrowserManager {
829
910
  }
830
911
  }
831
912
 
832
- // Check if contextual URL - expand from parent
913
+ // Check if contextual path - expand from parent
833
914
  // Note: The queueSourcePath optimization is handled by the caller (NativeAudioBrowser.navigateTrackAsync)
834
915
  // which has access to the existing queue and can skip to the track directly.
835
916
  // This matches Android where MediaSessionCallback handles the optimization before calling
836
917
  // BrowserManager.resolveMediaItemsForPlayback.
837
- if (trackUrl && BrowserPathHelper.isContextual(trackUrl)) {
838
- const parentPath = BrowserPathHelper.stripTrackId(trackUrl)
839
- const expanded = await this.expandQueueFromContextualUrl(trackUrl)
918
+ if (trackPath && BrowserPathHelper.isContextual(trackPath)) {
919
+ const parentPath = BrowserPathHelper.stripTrackId(trackPath)
920
+ const expanded = await this.expandQueueFromContextualPath(trackPath)
840
921
 
841
922
  if (expanded) {
842
923
  // Store source path for optimization by caller on next invocation
@@ -855,13 +936,13 @@ export class BrowserManager {
855
936
  }
856
937
 
857
938
  /**
858
- * Gets the default navigation path (first tab URL or '/').
939
+ * Gets the default navigation path (first tab path or '/').
859
940
  */
860
941
  private getDefaultPath(): string {
861
942
  // Try to get first tab as default path
862
943
  if (this._tabs && this._tabs.length > 0) {
863
944
  const firstTab = this._tabs[0]
864
- return firstTab?.url ?? '/'
945
+ return firstTab?.path ?? '/'
865
946
  }
866
947
  return '/'
867
948
  }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Track } from '../../types'
3
+ import { getTrackIdentity } from '../../utils/getTrackIdentity'
4
+ import { FavoriteManager } from './FavoriteManager'
5
+
6
+ const withId: Track = {
7
+ id: 'abc123',
8
+ src: 'https://cdn.example.com/streams/abc123.mp3?token=x',
9
+ title: 'With id'
10
+ }
11
+ const srcOnly: Track = {
12
+ src: 'https://cdn.example.com/streams/plain.mp3',
13
+ title: 'Src only'
14
+ }
15
+
16
+ describe('getTrackIdentity', () => {
17
+ it('is the id when set', () => {
18
+ expect(getTrackIdentity(withId)).toBe('abc123')
19
+ })
20
+
21
+ it('falls back to src when id is absent or blank', () => {
22
+ expect(getTrackIdentity(srcOnly)).toBe(srcOnly.src)
23
+ expect(getTrackIdentity({ id: '', src: 's' })).toBe('s')
24
+ })
25
+
26
+ it('is undefined for a browsable-only track', () => {
27
+ expect(getTrackIdentity({ title: 'Folder' } as Track)).toBeUndefined()
28
+ })
29
+ })
30
+
31
+ describe('FavoriteManager identity matching', () => {
32
+ it('matches a stored id against track.id, not src', () => {
33
+ const manager = new FavoriteManager()
34
+ manager.setFavorites(['abc123'])
35
+ expect(manager.hydrateFavorite(withId).favorited).toBe(true)
36
+ // The id does not substring-match into another track's src
37
+ expect(manager.hydrateFavorite(srcOnly).favorited).toBeUndefined()
38
+ })
39
+
40
+ it('matches a stored src for id-less tracks', () => {
41
+ const manager = new FavoriteManager()
42
+ manager.setFavorites([srcOnly.src!])
43
+ expect(manager.hydrateFavorite(srcOnly).favorited).toBe(true)
44
+ })
45
+
46
+ it('does not src-match a track whose id takes identity precedence', () => {
47
+ const manager = new FavoriteManager()
48
+ manager.setFavorites([withId.src!])
49
+ // identity of withId is its id, so a stored src must NOT match
50
+ expect(manager.hydrateFavorite(withId).favorited).toBeUndefined()
51
+ })
52
+
53
+ it('never overwrites a caller-set favorited flag', () => {
54
+ const manager = new FavoriteManager()
55
+ manager.setFavorites(['abc123'])
56
+ const preset: Track = { ...withId, favorited: false }
57
+ expect(manager.hydrateFavorite(preset).favorited).toBe(false)
58
+ })
59
+
60
+ it('add/remove key the cache by identity', () => {
61
+ const manager = new FavoriteManager()
62
+ manager.addFavorite(withId)
63
+ expect(manager.isFavorited(withId)).toBe(true)
64
+ // Same identity, different src (volatile query param) still matches
65
+ expect(
66
+ manager.isFavorited({ ...withId, src: 'https://other.example/x.mp3' })
67
+ ).toBe(true)
68
+ manager.removeFavorite(withId)
69
+ expect(manager.isFavorited(withId)).toBe(false)
70
+ })
71
+
72
+ it('hydrateChildren stamps favorited across a normalized page', () => {
73
+ const manager = new FavoriteManager()
74
+ manager.setFavorites(['abc123'])
75
+ const page = manager.hydrateChildren({
76
+ path: '/list',
77
+ title: 'List',
78
+ sections: [{ children: [withId, srcOnly] }]
79
+ })
80
+ const tracks = page.sections?.[0]?.children
81
+ expect(tracks?.[0]?.favorited).toBe(true)
82
+ expect(tracks?.[1]?.favorited).toBeUndefined()
83
+ })
84
+ })
@@ -1,14 +1,16 @@
1
1
  import type { Track, ResolvedTrack } from '../../types'
2
+ import { getTrackIdentity } from '../../utils/getTrackIdentity'
2
3
 
3
4
  /**
4
- * Manages favorite state for tracks.
5
+ * Manages favorite state for tracks, keyed by track identity (`id` when set,
6
+ * falling back to `src` — see getTrackIdentity / ADR 0008).
5
7
  * Matches Android's favorite hydration behavior.
6
8
  */
7
9
  export class FavoriteManager {
8
10
  private favorites = new Set<string>()
9
11
 
10
12
  /**
11
- * Sets the favorites cache from an array of track src values.
13
+ * Sets the favorites cache from an array of track identities.
12
14
  * This is typically called when favorites are loaded from storage.
13
15
  */
14
16
  setFavorites(favorites: string[]): void {
@@ -16,24 +18,27 @@ export class FavoriteManager {
16
18
  }
17
19
 
18
20
  /**
19
- * Checks if a track is favorited based on its src.
21
+ * Checks if a track is favorited based on its identity.
20
22
  */
21
- isFavorited(src: string): boolean {
22
- return this.favorites.has(src)
23
+ isFavorited(track: Track): boolean {
24
+ const identity = getTrackIdentity(track)
25
+ return identity !== undefined && this.favorites.has(identity)
23
26
  }
24
27
 
25
28
  /**
26
- * Adds a track src to the favorites cache.
29
+ * Adds a track to the favorites cache under its identity.
27
30
  */
28
- addFavorite(src: string): void {
29
- this.favorites.add(src)
31
+ addFavorite(track: Track): void {
32
+ const identity = getTrackIdentity(track)
33
+ if (identity !== undefined) this.favorites.add(identity)
30
34
  }
31
35
 
32
36
  /**
33
- * Removes a track src from the favorites cache.
37
+ * Removes a track from the favorites cache under its identity.
34
38
  */
35
- removeFavorite(src: string): void {
36
- this.favorites.delete(src)
39
+ removeFavorite(track: Track): void {
40
+ const identity = getTrackIdentity(track)
41
+ if (identity !== undefined) this.favorites.delete(identity)
37
42
  }
38
43
 
39
44
  /**
@@ -48,8 +53,7 @@ export class FavoriteManager {
48
53
  if (track.favorited !== undefined && track.favorited !== null) return track
49
54
  if (this.favorites.size === 0) return track
50
55
 
51
- const isFavorited = track.src ? this.favorites.has(track.src) : false
52
- if (!isFavorited) return track
56
+ if (!this.isFavorited(track)) return track
53
57
 
54
58
  return {
55
59
  ...track,
@@ -58,22 +62,23 @@ export class FavoriteManager {
58
62
  }
59
63
 
60
64
  /**
61
- * Hydrates favorites on all children of a ResolvedTrack.
62
- * Matches Android's hydrateChildren behavior.
65
+ * Hydrates favorites on all tracks of a resolved page.
66
+ * Matches Android's hydrateChildren behavior. Pages reach hydration
67
+ * normalized (ADR 0010), so `sections` is the only structure to walk.
63
68
  *
64
- * @param resolvedTrack ResolvedTrack with children to hydrate
65
- * @returns ResolvedTrack with favorited properties set on children
69
+ * @param resolvedTrack Resolved page to hydrate
70
+ * @returns Resolved page with favorited properties set on its tracks
66
71
  */
67
72
  hydrateChildren(resolvedTrack: ResolvedTrack): ResolvedTrack {
68
- const children = resolvedTrack.children
69
- if (!children) return resolvedTrack
73
+ const { sections } = resolvedTrack
74
+ if (!sections) return resolvedTrack
70
75
 
71
- const hydratedChildren = children.map((track) =>
72
- this.hydrateFavorite(track)
73
- )
74
76
  return {
75
77
  ...resolvedTrack,
76
- children: hydratedChildren
78
+ sections: sections.map((section) => ({
79
+ ...section,
80
+ children: section.children.map((track) => this.hydrateFavorite(track))
81
+ }))
77
82
  }
78
83
  }
79
84
  }
@@ -3,7 +3,7 @@ import { assertBrowsePageShape } from './assertBrowsePageShape'
3
3
 
4
4
  describe('assertBrowsePageShape', () => {
5
5
  it('passes a page object through unchanged', () => {
6
- const page = { url: '/x', title: 'X', children: [] }
6
+ const page = { path: '/x', title: 'X', children: [] }
7
7
  expect(assertBrowsePageShape(page, '/x')).toBe(page)
8
8
  })
9
9