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
@@ -12,6 +12,10 @@ import androidx.media3.session.SessionCommand
12
12
  import androidx.media3.session.SessionError
13
13
  import androidx.media3.session.SessionResult
14
14
  import com.audiobrowser.browser.handleTrackLoad
15
+ import com.audiobrowser.browser.normalizedSections
16
+ import com.audiobrowser.browser.styleResolvedSections
17
+ import com.audiobrowser.browser.untitledSection
18
+ import com.audiobrowser.extension.indexOfTappedTrack
15
19
  import com.audiobrowser.extension.toTrack
16
20
  import com.audiobrowser.util.BrowserPathHelper
17
21
  import com.audiobrowser.util.RatingFavorites
@@ -27,6 +31,8 @@ import com.margelo.nitro.audiobrowser.NativeGateRequest
27
31
  import com.margelo.nitro.audiobrowser.PlayerCapabilities
28
32
  import com.margelo.nitro.audiobrowser.RemoteButtonLayout
29
33
  import com.margelo.nitro.audiobrowser.SearchParams
34
+ import com.margelo.nitro.audiobrowser.Section
35
+ import com.margelo.nitro.audiobrowser.StyleDisplay
30
36
  import com.margelo.nitro.audiobrowser.Track
31
37
  import kotlinx.coroutines.CancellationException
32
38
  import kotlinx.coroutines.CoroutineScope
@@ -191,12 +197,17 @@ class MediaSessionCallback(private val player: Player) :
191
197
  command: SessionCommand,
192
198
  args: Bundle,
193
199
  ): ListenableFuture<SessionResult> {
194
- // Handle favorite button tap
200
+ // Handle favorite button tap. Report an honest result: a success here makes
201
+ // the controller flip its heart optimistically, so a no-op (no current
202
+ // track) must say INVALID_STATE instead of success-then-revert.
195
203
  if (command.customAction == MediaSessionCommandManager.CUSTOM_ACTION_FAVORITE) {
196
- val currentFavorited = player.currentTrack?.favorited ?: false
197
- Timber.d("Favorite button tapped - toggling from $currentFavorited to ${!currentFavorited}")
198
- player.setActiveTrackFavorited(!currentFavorited)
199
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
204
+ val applied = player.toggleActiveTrackFavorited()
205
+ Timber.d("Favorite button tapped - toggle applied=$applied")
206
+ return Futures.immediateFuture(
207
+ SessionResult(
208
+ if (applied) SessionResult.RESULT_SUCCESS else SessionResult.RESULT_ERROR_INVALID_STATE
209
+ )
210
+ )
200
211
  }
201
212
 
202
213
  if (commandManager.handleCustomCommand(command, player)) {
@@ -212,8 +223,16 @@ class MediaSessionCallback(private val player: Player) :
212
223
  ): ListenableFuture<SessionResult> {
213
224
  // A heart rating from a controller (e.g. Google Assistant "I like this") toggles the
214
225
  // now-playing favorite. setActiveTrackFavorited fires onFavoriteChanged so the consumer
215
- // persists it — the same path as the notification / CarPlay heart button.
216
- RatingFavorites.favoritedFor(rating)?.let { player.setActiveTrackFavorited(it) }
226
+ // persists it — the same path as the notification / CarPlay heart button. Report an honest
227
+ // result (see onCustomCommand): INVALID_STATE when there is no current track to favorite.
228
+ RatingFavorites.favoritedFor(rating)?.let { favorited ->
229
+ val applied = player.setActiveTrackFavorited(favorited)
230
+ return Futures.immediateFuture(
231
+ SessionResult(
232
+ if (applied) SessionResult.RESULT_SUCCESS else SessionResult.RESULT_ERROR_INVALID_STATE
233
+ )
234
+ )
235
+ }
217
236
  return super.onSetRating(session, controller, rating)
218
237
  }
219
238
 
@@ -238,13 +257,11 @@ class MediaSessionCallback(private val player: Player) :
238
257
  }
239
258
 
240
259
  if (params?.isRecent == true) {
241
- // TODO: support recent queries through something like onRecent and return a MediaItem with
242
- // .setMediaId("__RECENT__") here - when onRecent is not configured we can keep returning an
243
- // error here:
244
-
245
- // The service currently does not support playback resumption. Tell System UI by returning
246
- // an error of type 'RESULT_ERROR_NOT_SUPPORTED' for a `params.isRecent` request. See
247
- // https://github.com/androidx/media/issues/355
260
+ // Playback resumption is served by media3 itself: it intercepts System UI's EXTRA_RECENT
261
+ // root request internally and answers through onPlaybackResumption, so that request never
262
+ // reaches this callback. Any other browser asking for a recent root gets
263
+ // ERROR_NOT_SUPPORTED — there is no browsable recents tree; recents are ordinary consumer
264
+ // content (a tab or route).
248
265
  return Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED))
249
266
  }
250
267
 
@@ -271,7 +288,7 @@ class MediaSessionCallback(private val player: Player) :
271
288
  params: MediaLibraryService.LibraryParams?,
272
289
  ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
273
290
  Timber.d(
274
- "onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize, isSearchPath: ${BrowserPathHelper.isSpecialPath(parentId)} }"
291
+ "onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize, isSpecialPath: ${BrowserPathHelper.isSpecialPath(parentId)} }"
275
292
  )
276
293
  return scope.future {
277
294
  // Wait for browser to be registered if it's not available yet
@@ -325,30 +342,28 @@ class MediaSessionCallback(private val player: Player) :
325
342
 
326
343
  try {
327
344
  val children =
328
- if (parentId == BrowserPathHelper.RECENT_PATH) {
329
- // TODO: implement recent media items
330
- emptyList<MediaItem>()
331
- } else if (parentId == BrowserPathHelper.ROOT_PATH) {
345
+ if (parentId == BrowserPathHelper.ROOT_PATH) {
332
346
  // Return tabs as root children (limited to 4 for automotive platform compatibility)
333
347
  // TODO: Check what Android Auto does with empty tabs list - may need to return error?
334
- val tabs = browserManager.queryTabs()
348
+ // Disabled tabs hide (Track.disabled) — filtered before the cap so
349
+ // an unavailable tab never costs an available one its slot.
350
+ val tabs = browserManager.queryTabs().filterNot { it.disabled == true }
335
351
  if (tabs.size > 4) {
336
352
  Timber.w(
337
353
  "Root has ${tabs.size} tabs; dropping ${tabs.size - 4} (Android Auto root limit)"
338
354
  )
339
355
  }
340
- toMediaItems(tabs.take(4))
356
+ toFlatMediaItems(tabs.take(4))
341
357
  } else {
342
- // Resolve the specific path and get its children
358
+ // Resolve the specific path and flatten its sections to MediaItems
359
+ // (paths are already set to contextual paths). Section styles come
360
+ // page-folded (`section ?? page`, ADR 0011).
343
361
  val resolvedTrack = browserManager.resolve(parentId)
344
-
345
- // Convert children to MediaItems (url is already set to contextual URL)
346
- val trackChildren =
347
- resolvedTrack.children
348
- ?: throw IllegalStateException(
349
- "Expected browsed ResolvedTrack to have a children array"
350
- )
351
- toMediaItems(trackChildren.toList())
362
+ val sections =
363
+ resolvedTrack.styleResolvedSections()
364
+ ?: throw IllegalStateException("Expected browsed ResolvedTrack to have sections")
365
+ browserManager.warnIfGridPageLacksPromise(parentId, sections)
366
+ toMediaItems(sections)
352
367
  }
353
368
 
354
369
  LibraryResult.ofItemList(ImmutableList.copyOf(children.paginate(page, pageSize)), params)
@@ -402,19 +417,45 @@ class MediaSessionCallback(private val player: Player) :
402
417
  }
403
418
 
404
419
  /**
405
- * Converts tracks to MediaItems for browse delivery, routing http(s) artwork through the
406
- * content:// provider so Android Auto can load it via the ArtworkContentProvider. Image-row
407
- * tracks (a CarPlay-only rendering) are expanded into their items as regular grouped rows first —
408
- * see [TrackFactory.expandImageRows].
420
+ * Flattens a page's sections to MediaItems for browse delivery — the Media3 boundary where
421
+ * sections die (ADR 0010): the MediaBrowser protocol has no section node, so each child is
422
+ * stamped with its owning section's title/style hints. A grid-displayed section with a `path`
423
+ * gains a browsable "view all" link under the same header. Http(s) artwork routes through the
424
+ * content:// provider so Android Auto can load it via the ArtworkContentProvider.
409
425
  */
410
- private fun toMediaItems(tracks: List<Track>): List<MediaItem> {
426
+ private fun toMediaItems(sections: List<Section>): List<MediaItem> {
411
427
  val registry = player.browseArtworkRegistry
412
428
  val authority = com.audiobrowser.util.ArtworkUris.authorityFor(player.context.packageName)
413
- return TrackFactory.expandImageRows(tracks).map {
414
- TrackFactory.toBrowseMediaItem(it, registry, authority)
429
+ return sections.flatMap { section ->
430
+ // Android Auto has no disabled affordance, so an unavailable track hides
431
+ // — never a normal-looking dead row (Track.disabled's rendering ladder).
432
+ val visibleChildren = section.children.filter { it.disabled != true }
433
+ val items =
434
+ visibleChildren.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
435
+ val isTileSection = section.style?.display == StyleDisplay.GRID
436
+ // No "view all" under an empty section — CarPlay skips empty sections
437
+ // outright, and a lone navigation tile under a header is a dead end.
438
+ if (isTileSection && section.path != null && visibleChildren.isNotEmpty()) {
439
+ items +
440
+ TrackFactory.toBrowseMediaItem(
441
+ TrackFactory.navigationTrack(section),
442
+ registry,
443
+ authority,
444
+ section,
445
+ )
446
+ } else {
447
+ items
448
+ }
415
449
  }
416
450
  }
417
451
 
452
+ /**
453
+ * Flat lists (tabs, search results) convert as one untitled list section — which contributes no
454
+ * group or style hints — so exactly one Track→MediaItem path exists at the Media3 boundary.
455
+ */
456
+ private fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
457
+ toMediaItems(listOf(untitledSection(tracks.toTypedArray())))
458
+
418
459
  override fun onGetItem(
419
460
  session: MediaLibraryService.MediaLibrarySession,
420
461
  browser: MediaSession.ControllerInfo,
@@ -451,7 +492,7 @@ class MediaSessionCallback(private val player: Player) :
451
492
  return@future LibraryResult.ofItem(createGateMediaItem(outcome.chrome!!), null)
452
493
  }
453
494
 
454
- if (mediaId == BrowserPathHelper.ROOT_PATH || mediaId == BrowserPathHelper.RECENT_PATH) {
495
+ if (mediaId == BrowserPathHelper.ROOT_PATH) {
455
496
  return@future LibraryResult.ofItem(
456
497
  MediaItem.Builder()
457
498
  .setMediaId(mediaId)
@@ -466,7 +507,7 @@ class MediaSessionCallback(private val player: Player) :
466
507
  // Wait for browser to be registered if it's not available yet
467
508
  val browserManager = player.awaitBrowser().browserManager
468
509
 
469
- // Serve tracks from the track cache first (keyed by url and src). Besides avoiding an HTTP
510
+ // Serve tracks from the track cache first (keyed by path and src). Besides avoiding an HTTP
470
511
  // resolve, this keeps item identity correct for contextual mediaIds: resolve() strips
471
512
  // __trackId and would return the *parent container's* metadata as the item.
472
513
  val browseAuthority =
@@ -628,7 +669,12 @@ class MediaSessionCallback(private val player: Player) :
628
669
  try {
629
670
  // Execute search (automatically caches results at /__search?q=query)
630
671
  val searchResults = browserManager.search(query)
631
- val resultCount = searchResults.children?.size ?: 0
672
+ // Count what onGetSearchResult will actually serve: disabled tracks
673
+ // hide on Android Auto, so they must not inflate the announced count.
674
+ val resultCount =
675
+ searchResults.normalizedSections?.sumOf { section ->
676
+ section.children.count { it.disabled != true }
677
+ } ?: 0
632
678
 
633
679
  Timber.d("Search completed: $resultCount results for query '$query'")
634
680
 
@@ -681,14 +727,9 @@ class MediaSessionCallback(private val player: Player) :
681
727
  try {
682
728
  // Get cached search results from BrowserManager
683
729
  browserManager.getCachedSearchResults(query)?.let { tracks ->
684
- // Convert to MediaItems
685
- val searchAuthority =
686
- com.audiobrowser.util.ArtworkUris.authorityFor(player.context.packageName)
687
- val mediaItems =
688
- tracks.map { track ->
689
- Timber.d("Search result: ${track.title} (url=${track.url}, src=${track.src})")
690
- TrackFactory.toBrowseMediaItem(track, player.browseArtworkRegistry, searchAuthority)
691
- }
730
+ // Through the shared browse conversion, so search results get the
731
+ // same artwork routing as browse rows.
732
+ val mediaItems = toFlatMediaItems(tracks.toList())
692
733
 
693
734
  val paginatedItems = mediaItems.paginate(page, pageSize)
694
735
  Timber.d("Returning ${paginatedItems.size} search results")
@@ -737,26 +778,28 @@ class MediaSessionCallback(private val player: Player) :
737
778
 
738
779
  val browserManager = audioBrowser.browserManager
739
780
 
740
- // A single tapped item resolves to the contextual url of the list it was
781
+ // A single tapped item resolves to the contextual path of the list it was
741
782
  // tapped in: directly for contextual mediaIds, via the track cache for
742
- // stable-id mediaIds (see BrowserManager.contextualUrlFor). A search
783
+ // stable-id mediaIds (see BrowserManager.contextualPathFor). A search
743
784
  // selection is not a list tap — its queue comes from the search results
744
785
  // (resolveMediaItemsForPlayback), never from a browsed container.
745
- val singleContextualUrl =
786
+ val singleContextualPath =
746
787
  mediaItems
747
788
  .singleOrNull()
748
789
  ?.takeIf { it.requestMetadata.searchQuery == null }
749
- ?.let { browserManager.contextualUrlFor(it.mediaId) }
790
+ ?.let { browserManager.contextualPathFor(it.mediaId) }
750
791
 
751
792
  // Check if this is a single item from the current queue source
752
- if (singleContextualUrl != null) {
753
- val parentPath = BrowserPathHelper.stripTrackId(singleContextualUrl)
754
- val trackId = BrowserPathHelper.extractTrackId(singleContextualUrl)
793
+ if (singleContextualPath != null) {
794
+ val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
795
+ val trackId = BrowserPathHelper.extractTrackId(singleContextualPath)
755
796
 
756
- // Check if queue already came from this parent path - just skip to the track
797
+ // Check if queue already came from this parent path - just skip to the
798
+ // tapped surface (exact path first, identity for index-less paths —
799
+ // see indexOfTappedTrack)
757
800
  if (trackId != null && parentPath == player.queueSourcePath) {
758
801
  val queueTracks = withContext(Dispatchers.Main) { player.tracks }
759
- val index = queueTracks.indexOfFirst { it.src == trackId }
802
+ val index = queueTracks.indexOfTappedTrack(singleContextualPath, trackId)
760
803
  if (index >= 0) {
761
804
  Timber.d("Queue already from $parentPath, skipping to index $index")
762
805
  val track = queueTracks[index]
@@ -788,9 +831,10 @@ class MediaSessionCallback(private val player: Player) :
788
831
  result.startIndex.toDouble(),
789
832
  ::currentPlayerState,
790
833
  ) {
791
- // If this was a contextual URL expansion, track the source path (only for default behavior)
792
- if (singleContextualUrl != null) {
793
- val parentPath = BrowserPathHelper.stripTrackId(singleContextualUrl)
834
+ // If this was a contextual path expansion, track the source path (only for default
835
+ // behavior)
836
+ if (singleContextualPath != null) {
837
+ val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
794
838
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
795
839
  }
796
840
  result
@@ -829,6 +873,15 @@ class MediaSessionCallback(private val player: Player) :
829
873
  throw IllegalStateException("No playback state to resume")
830
874
  }
831
875
 
876
+ // The persisted track can carry `disabled` — it became unavailable after
877
+ // it was saved. A disabled track never plays, resumption included
878
+ // (Track.disabled); failing the future refuses without touching the
879
+ // player.
880
+ if (state.track.disabled == true) {
881
+ Timber.w("Refusing resumption of disabled track: ${state.track.title}")
882
+ throw IllegalStateException("Persisted track is disabled")
883
+ }
884
+
832
885
  // isForPlayback == false is the device-boot-time notification case: network may be
833
886
  // unavailable and we must not start playback. Return just the locally-stored track with its
834
887
  // already-local metadata; skip the settings restore and network queue expansion below.
@@ -844,14 +897,14 @@ class MediaSessionCallback(private val player: Player) :
844
897
  // restore() sets player properties which must happen on main thread
845
898
  withContext(Dispatchers.Main) { player.playbackStateStore.restore() }
846
899
 
847
- val url = state.track.url
848
- Timber.d("Resuming from url=$url, positionMs=${state.positionMs}")
900
+ val path = state.track.path
901
+ Timber.d("Resuming from path=$path, positionMs=${state.positionMs}")
849
902
 
850
903
  // Wait for browser to be available (JS needs to have configured it)
851
904
  val browserManager = player.awaitBrowser().browserManager
852
905
 
853
- // Try to expand the URL into a full queue
854
- val expanded = url?.let { browserManager.expandQueueFromContextualUrl(it) }
906
+ // Try to expand the path into a full queue
907
+ val expanded = path?.let { browserManager.expandQueueFromContextualPath(it) }
855
908
 
856
909
  if (expanded != null) {
857
910
  val (tracks, selectedIndex) = expanded
@@ -859,9 +912,9 @@ class MediaSessionCallback(private val player: Player) :
859
912
  "Restored ${tracks.size} tracks, starting at index $selectedIndex at ${state.positionMs}ms"
860
913
  )
861
914
 
862
- // Track the source path if this was a contextual URL expansion
863
- if (BrowserPathHelper.isContextual(url)) {
864
- val parentPath = BrowserPathHelper.stripTrackId(url)
915
+ // Track the source path if this was a contextual path expansion
916
+ if (BrowserPathHelper.isContextual(path)) {
917
+ val parentPath = BrowserPathHelper.stripTrackId(path)
865
918
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
866
919
  }
867
920
 
@@ -1,5 +1,6 @@
1
1
  package com.audiobrowser.player
2
2
 
3
+ import com.audiobrowser.extension.identity
3
4
  import com.audiobrowser.util.url
4
5
  import com.margelo.nitro.audiobrowser.FormatNowPlayingParams
5
6
  import com.margelo.nitro.audiobrowser.NowPlayingMetadata
@@ -105,10 +106,11 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
105
106
  private var lastPublished: Published? = null
106
107
 
107
108
  /**
108
- * The track id whose now-playing artwork has been resolved (or is being resolved), so a
109
- * `nowPlayingArtwork` resolve runs once per track instead of on every render.
109
+ * The track identity (id-or-src, falling back to path — same key as [Published]) whose
110
+ * now-playing artwork has been resolved (or is being resolved), so a `nowPlayingArtwork` resolve
111
+ * runs once per track — id-less tracks included — instead of on every render.
110
112
  */
111
- private var artworkResolvedForTrackId: String? = null
113
+ private var artworkResolvedForTrack: String? = null
112
114
 
113
115
  /** Size hint (px, square) for now-playing artwork. Mirrors iOS (screen-width-capped to 1200). */
114
116
  private val artworkSizePx = 1200.0
@@ -164,7 +166,7 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
164
166
  duration = track.duration,
165
167
  artwork = track.artwork?.url,
166
168
  description = track.description,
167
- mediaId = track.src ?: track.url,
169
+ mediaId = track.src ?: track.path,
168
170
  genre = track.genre,
169
171
  )
170
172
  }
@@ -227,7 +229,7 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
227
229
  // default on null/throw.
228
230
  val activeFormatter = if (enabled) formatter else null
229
231
  if (activeFormatter != null) {
230
- val capturedId = track.src ?: track.url
232
+ val capturedId = track.src ?: track.path
231
233
  // Gate the raw load-control signal to the buffering state so `stalled` is correct on its
232
234
  // own: ExoPlayer's rebuffering flag is polled on a different cadence than state transitions
233
235
  // and can linger true into the PLAYING transition as a rebuffer recovers. Classify by
@@ -266,7 +268,7 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
266
268
  formatted != null &&
267
269
  current != null &&
268
270
  currentIdx != null &&
269
- (current.src ?: current.url) == capturedId &&
271
+ (current.src ?: current.path) == capturedId &&
270
272
  renderGeneration == generation
271
273
  ) {
272
274
  applyFields(
@@ -293,7 +295,7 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
293
295
  // the same fields are recomputed often; an unconditional stamp would churn the MediaSession
294
296
  // (and flicker Android Auto / now-playing) for no visible change. Keyed on the track identity
295
297
  // so a new track with a coincidentally identical line still publishes.
296
- val published = Published(index, track.src ?: track.url, title, secondaryLine, album)
298
+ val published = Published(index, track.identity ?: track.path, title, secondaryLine, album)
297
299
  if (published == lastPublished) return
298
300
  lastPublished = published
299
301
 
@@ -307,28 +309,23 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
307
309
 
308
310
  /**
309
311
  * Resolves the playing track's now-playing artwork and stamps it onto the playing media item.
310
- * Guarded exactly like iOS: only when `nowPlayingArtwork` is configured AND the track has a
311
- * non-empty id (so the `{id}` token never resolves to an empty string). Otherwise the existing
312
- * artworkUri — which came from `artwork` (browse list path) — is left in place.
312
+ * Runs for EVERY playing track when `nowPlayingArtwork` is configured — id or not, exactly like
313
+ * iOS (an id-less track leaves any `{id}` token unfilled; the resolver warns and the request
314
+ * visibly 404s). Without a config, the existing artworkUri — which came from `artwork` (browse
315
+ * list path) — is left in place.
313
316
  */
314
317
  private fun maybeResolveArtwork(track: Track) {
315
- val trackId =
316
- track.id?.takeIf { it.isNotEmpty() }
317
- ?: run {
318
- // No id → skip nowPlayingArtwork entirely; keep the existing (browse) artworkUri.
319
- artworkResolvedForTrackId = null
320
- return
321
- }
322
-
323
318
  if (!surface.hasNowPlayingArtworkConfig) {
324
319
  // No now-playing artwork config (or no browser) → fall back to the existing artworkUri.
325
- artworkResolvedForTrackId = null
320
+ artworkResolvedForTrack = null
326
321
  return
327
322
  }
328
323
 
329
- // Already resolved (or resolving) for this track id — avoid a redundant resolve per render.
330
- if (artworkResolvedForTrackId == trackId) return
331
- artworkResolvedForTrackId = trackId
324
+ // Already resolved (or resolving) for this track — avoid a redundant resolve per render.
325
+ // Keyed on track identity (id-or-src, falling back to path) so id-less tracks dedupe too.
326
+ val trackKey = track.identity ?: track.path
327
+ if (trackKey != null && artworkResolvedForTrack == trackKey) return
328
+ artworkResolvedForTrack = trackKey
332
329
 
333
330
  scope.launch {
334
331
  val uri =
@@ -336,10 +333,10 @@ class NowPlayingUpdater(private val surface: NowPlayingSurface, private val scop
336
333
  ?: return@launch
337
334
 
338
335
  // Apply only if still the same track (a fast skip must not be overwritten by a stale
339
- // result).
336
+ // result), compared on the same identity key the resolve was launched under.
340
337
  val currentIdx = surface.currentIndex ?: return@launch
341
338
  val current = surface.currentTrack ?: return@launch
342
- if (current.id != trackId) return@launch
339
+ if ((current.identity ?: current.path) != trackKey) return@launch
343
340
 
344
341
  surface.stampArtwork(currentIdx, current, uri)
345
342
  }
@@ -5,6 +5,9 @@ import android.content.SharedPreferences
5
5
  import androidx.core.content.edit
6
6
  import androidx.media3.common.C
7
7
  import com.audiobrowser.util.artworkOf
8
+ import com.audiobrowser.util.toArtworkRendering
9
+ import com.audiobrowser.util.toStyleDisplay
10
+ import com.audiobrowser.util.toWireString
8
11
  import com.audiobrowser.util.url
9
12
  import com.margelo.nitro.audiobrowser.PlaybackState
10
13
  import com.margelo.nitro.audiobrowser.RepeatMode
@@ -18,12 +21,12 @@ import org.json.JSONObject
18
21
  import timber.log.Timber
19
22
 
20
23
  /**
21
- * Persists playback state for resumption after app restart. Stores the current track URL, position,
22
- * and player settings to enable playback resumption via MediaButtonReceiver (Bluetooth play button,
23
- * etc.).
24
+ * Persists playback state for resumption after app restart. Stores the current track (including its
25
+ * path), position, and player settings to enable playback resumption via MediaButtonReceiver
26
+ * (Bluetooth play button, etc.).
24
27
  *
25
28
  * Settings (repeatMode, shuffleEnabled, playbackSpeed) are auto-persisted when set via properties.
26
- * Position/URL are persisted via save() on pause or track change.
29
+ * Position/track are persisted via save() on pause or track change.
27
30
  */
28
31
  class PlaybackStateStore(private val player: Player) {
29
32
  private val prefs: SharedPreferences =
@@ -32,7 +35,7 @@ class PlaybackStateStore(private val player: Player) {
32
35
  /**
33
36
  * Persisted playback state for resumption.
34
37
  *
35
- * @param track The track being played (includes url for queue expansion)
38
+ * @param track The track being played (includes path for queue expansion)
36
39
  * @param positionMs The playback position in milliseconds (C.TIME_UNSET for default/live edge)
37
40
  * @param repeatMode The repeat mode setting
38
41
  * @param shuffleEnabled Whether shuffle mode was enabled
@@ -148,78 +151,11 @@ class PlaybackStateStore(private val player: Player) {
148
151
  Timber.d("Saved position: positionMs=0 (queue ended)")
149
152
  }
150
153
 
151
- private fun trackToJson(track: Track): String =
152
- JSONObject()
153
- .apply {
154
- put("id", track.id)
155
- put("url", track.url)
156
- put("src", track.src)
157
- put("title", track.title)
158
- put("subtitle", track.subtitle)
159
- put("artist", track.artist)
160
- put("albumUrl", track.albumUrl)
161
- put("album", track.album)
162
- put("artwork", track.artwork?.url)
163
- put("description", track.description)
164
- put("genre", track.genre)
165
- put("duration", track.duration)
166
- put("style", track.style?.name)
167
- put("childrenStyle", track.childrenStyle?.name)
168
- put("favorited", track.favorited)
169
- put("groupTitle", track.groupTitle)
170
- put("live", track.live)
171
- }
172
- .toString()
173
-
174
- private fun trackFromJson(json: String): Track? =
175
- runCatching {
176
- val obj = JSONObject(json)
177
- Track(
178
- id = obj.optString("id").takeIf { it.isNotEmpty() },
179
- url = obj.optString("url").takeIf { it.isNotEmpty() },
180
- src = obj.optString("src").takeIf { it.isNotEmpty() },
181
- title = obj.getString("title"),
182
- subtitle = obj.optString("subtitle").takeIf { it.isNotEmpty() },
183
- artist = obj.optString("artist").takeIf { it.isNotEmpty() },
184
- albumUrl = obj.optString("albumUrl").takeIf { it.isNotEmpty() },
185
- album = obj.optString("album").takeIf { it.isNotEmpty() },
186
- artwork = artworkOf(obj.optString("artwork").takeIf { it.isNotEmpty() }),
187
- artworkSource = null, // Not persisted - will be re-transformed on browse
188
- artworkCarPlayTinted = null, // iOS-only, not persisted
189
- description = obj.optString("description").takeIf { it.isNotEmpty() },
190
- genre = obj.optString("genre").takeIf { it.isNotEmpty() },
191
- duration =
192
- if (obj.has("duration") && !obj.isNull("duration")) obj.getDouble("duration") else null,
193
- style =
194
- obj
195
- .optString("style")
196
- .takeIf { it.isNotEmpty() }
197
- ?.let { runCatching { TrackStyle.valueOf(it) }.getOrNull() },
198
- childrenStyle =
199
- obj
200
- .optString("childrenStyle")
201
- .takeIf { it.isNotEmpty() }
202
- ?.let { runCatching { TrackStyle.valueOf(it) }.getOrNull() },
203
- favorited =
204
- if (obj.has("favorited") && !obj.isNull("favorited")) obj.getBoolean("favorited")
205
- else null,
206
- groupTitle = obj.optString("groupTitle").takeIf { it.isNotEmpty() },
207
- live = if (obj.has("live") && !obj.isNull("live")) obj.getBoolean("live") else null,
208
- // Not persisted: on resumption the contextual URL is re-browsed
209
- // (expandQueueFromContextualUrl), which re-parses each track's request
210
- // from the API's current response and re-caches it.
211
- request = null,
212
- imageRow = null, // Not persisted
213
- )
214
- }
215
- .onFailure { e -> Timber.w(e, "Failed to parse persisted track JSON") }
216
- .getOrNull()
217
-
218
154
  /**
219
155
  * Restores player settings from persisted state and returns the state for queue setup.
220
156
  *
221
157
  * Applies repeatMode, shuffleMode, and playbackSpeed to the player. The caller is responsible for
222
- * using the returned url and positionMs to set up the playback queue.
158
+ * using the returned path and positionMs to set up the playback queue.
223
159
  *
224
160
  * @return PersistedState if available, null otherwise
225
161
  */
@@ -255,6 +191,90 @@ class PlaybackStateStore(private val player: Player) {
255
191
  }
256
192
 
257
193
  companion object {
194
+ // Internal (not private) so the round-trip is testable without a Player.
195
+ internal fun trackToJson(track: Track): String =
196
+ JSONObject()
197
+ .apply {
198
+ put("id", track.id)
199
+ put("path", track.path)
200
+ put("src", track.src)
201
+ put("title", track.title)
202
+ put("subtitle", track.subtitle)
203
+ put("artist", track.artist)
204
+ put("albumPath", track.albumPath)
205
+ put("album", track.album)
206
+ put("artwork", track.artwork?.url)
207
+ put("description", track.description)
208
+ put("genre", track.genre)
209
+ put("duration", track.duration)
210
+ // The style block persists as its wire shape (nested object, wire
211
+ // strings) and `disabled` beside it: a restored queue keeps its
212
+ // visible fidelity across process death (ADR 0011).
213
+ put(
214
+ "style",
215
+ track.style?.let { style ->
216
+ JSONObject().apply {
217
+ put("display", style.display?.toWireString())
218
+ put("artworkRendering", style.artworkRendering?.toWireString())
219
+ }
220
+ },
221
+ )
222
+ put("disabled", track.disabled)
223
+ put("favorited", track.favorited)
224
+ put("live", track.live)
225
+ }
226
+ .toString()
227
+
228
+ internal fun trackFromJson(json: String): Track? =
229
+ runCatching {
230
+ val obj = JSONObject(json)
231
+ Track(
232
+ id = obj.optString("id").takeIf { it.isNotEmpty() },
233
+ path = obj.optString("path").takeIf { it.isNotEmpty() },
234
+ src = obj.optString("src").takeIf { it.isNotEmpty() },
235
+ title = obj.getString("title"),
236
+ subtitle = obj.optString("subtitle").takeIf { it.isNotEmpty() },
237
+ artist = obj.optString("artist").takeIf { it.isNotEmpty() },
238
+ albumPath = obj.optString("albumPath").takeIf { it.isNotEmpty() },
239
+ album = obj.optString("album").takeIf { it.isNotEmpty() },
240
+ artwork = artworkOf(obj.optString("artwork").takeIf { it.isNotEmpty() }),
241
+ artworkSource = null, // Not persisted - will be re-transformed on browse
242
+ description = obj.optString("description").takeIf { it.isNotEmpty() },
243
+ genre = obj.optString("genre").takeIf { it.isNotEmpty() },
244
+ duration =
245
+ if (obj.has("duration") && !obj.isNull("duration")) obj.getDouble("duration")
246
+ else null,
247
+ // Tolerant (ADR 0011): a snapshot written before the block model —
248
+ // `style` as an enum-name string — is not an object, so optJSONObject
249
+ // yields null and the track restores without a declaration.
250
+ style =
251
+ obj
252
+ .optJSONObject("style")
253
+ ?.let { style ->
254
+ TrackStyle(
255
+ display = style.optString("display").toStyleDisplay(),
256
+ artworkRendering = style.optString("artworkRendering").toArtworkRendering(),
257
+ )
258
+ }
259
+ // An empty block collapses to "no declaration" — one shape on
260
+ // every platform, matching the browse decoders.
261
+ ?.takeIf { it.display != null || it.artworkRendering != null },
262
+ disabled =
263
+ if (obj.has("disabled") && !obj.isNull("disabled")) obj.getBoolean("disabled")
264
+ else null,
265
+ favorited =
266
+ if (obj.has("favorited") && !obj.isNull("favorited")) obj.getBoolean("favorited")
267
+ else null,
268
+ live = if (obj.has("live") && !obj.isNull("live")) obj.getBoolean("live") else null,
269
+ // Not persisted: on resumption the contextual path is re-browsed
270
+ // (expandQueueFromContextualPath), which re-parses each track's request
271
+ // from the API's current response and re-caches it.
272
+ request = null,
273
+ )
274
+ }
275
+ .onFailure { e -> Timber.w(e, "Failed to parse persisted track JSON") }
276
+ .getOrNull()
277
+
258
278
  private const val PREFS_NAME = "audio_browser_playback_state_v3"
259
279
  private const val KEY_POSITION_MS = "position_ms"
260
280
  private const val KEY_REPEAT_MODE = "repeat_mode"