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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (416) hide show
  1. package/README.md +14 -4
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +115 -26
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +262 -188
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +23 -3
  6. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  7. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +95 -58
  8. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +68 -26
  9. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +60 -0
  10. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +19 -7
  11. package/android/src/main/java/com/audiobrowser/extension/TappedTrack.kt +20 -0
  12. package/android/src/main/java/com/audiobrowser/extension/TrackIdentity.kt +15 -0
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  14. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +120 -67
  15. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +21 -24
  16. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +109 -74
  17. package/android/src/main/java/com/audiobrowser/player/Player.kt +51 -30
  18. package/android/src/main/java/com/audiobrowser/player/PlayerCapabilitiesExt.kt +2 -15
  19. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  20. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +44 -40
  21. package/android/src/main/java/com/audiobrowser/util/MediaExtrasBuilder.kt +28 -23
  22. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +108 -0
  23. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +52 -53
  24. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +74 -41
  25. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerFavoritesTest.kt +153 -0
  26. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +229 -50
  27. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +38 -0
  28. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  29. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +1 -1
  30. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +147 -0
  31. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +128 -53
  32. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +154 -0
  33. package/android/src/test/java/com/audiobrowser/extension/TappedTrackTest.kt +43 -0
  34. package/android/src/test/java/com/audiobrowser/extension/TrackIdentityTest.kt +41 -0
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +1 -2
  36. package/android/src/test/java/com/audiobrowser/player/NowPlayingUpdaterTest.kt +42 -2
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +55 -0
  38. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  39. package/android/src/test/java/com/audiobrowser/util/BrowserPathHelperTest.kt +41 -16
  40. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +161 -0
  41. package/android/src/test/java/com/audiobrowser/util/TrackFactoryMediaIdTest.kt +7 -7
  42. package/ios/Browser/BrowserConfig.swift +102 -5
  43. package/ios/Browser/BrowserManager+URLResolution.swift +18 -14
  44. package/ios/Browser/BrowserManager.swift +229 -180
  45. package/ios/Browser/BrowserPathHelper.swift +44 -44
  46. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  47. package/ios/Browser/JsonModels.swift +258 -106
  48. package/ios/Browser/SearchDrillIn.swift +6 -6
  49. package/ios/Browser/SectionPresentation.swift +64 -0
  50. package/ios/Browser/SectionScope.swift +58 -24
  51. package/ios/Browser/StyleResolver.swift +58 -0
  52. package/ios/Browser/TabBarEntries.swift +9 -3
  53. package/ios/Browser/TrackIdTemplate.swift +37 -0
  54. package/ios/CarPlay/CarPlayArtworkResolver.swift +8 -7
  55. package/ios/CarPlay/CarPlayController.swift +227 -85
  56. package/ios/CarPlay/CarPlayImageLoader.swift +38 -18
  57. package/ios/CarPlay/CarPlayListItemFactory.swift +289 -84
  58. package/ios/CarPlay/CarPlayNowPlayingManager.swift +15 -12
  59. package/ios/Extension/Capability+RemoteCommand.swift +1 -1
  60. package/ios/Extension/PlayerCapabilities+Favorite.swift +1 -15
  61. package/ios/Extension/ResolvedTrack+Copying.swift +11 -15
  62. package/ios/Extension/Section+Copying.swift +31 -0
  63. package/ios/Extension/Track+Copying.swift +6 -12
  64. package/ios/Extension/Track+Identity.swift +18 -0
  65. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  66. package/ios/Http/HttpClient.swift +8 -2
  67. package/ios/HybridAudioBrowser.swift +166 -45
  68. package/ios/Model/NitroTypeStubs.swift +106 -21
  69. package/ios/Model/PlayerUpdateOptions.swift +7 -0
  70. package/ios/Player/MediaIntentCriteria.swift +13 -5
  71. package/ios/Player/MediaLoader.swift +33 -9
  72. package/ios/Player/NowPlayingUpdater.swift +54 -6
  73. package/ios/Player/PlaybackCoordinator.swift +13 -6
  74. package/ios/Player/PlaybackTimer.swift +8 -2
  75. package/ios/Player/QueueManager.swift +36 -10
  76. package/ios/Player/RetryManager.swift +4 -3
  77. package/ios/Player/ShuffleOrder.swift +23 -0
  78. package/ios/Player/SleepTimerHandling.swift +13 -0
  79. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  80. package/ios/Tests/BrowserPathHelperTests.swift +50 -20
  81. package/ios/Tests/CarPlayArtworkResolverTests.swift +24 -41
  82. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  83. package/ios/Tests/JsonModelsTests.swift +150 -1
  84. package/ios/Tests/PlaybackStateStoreTests.swift +4 -3
  85. package/ios/Tests/QueueManagerTests.swift +72 -6
  86. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  87. package/ios/Tests/ResolvedTrackCopyingTests.swift +37 -35
  88. package/ios/Tests/SearchDrillInTests.swift +16 -16
  89. package/ios/Tests/SectionPresentationTests.swift +90 -0
  90. package/ios/Tests/SectionScopeTests.swift +87 -55
  91. package/ios/Tests/StyleResolverTests.swift +127 -0
  92. package/ios/Tests/Support/TestTypes.swift +7 -0
  93. package/ios/Tests/TabBarEntriesTests.swift +18 -18
  94. package/ios/Tests/TrackIdTemplateTests.swift +67 -0
  95. package/ios/Tests/TrackIdentityTests.swift +20 -0
  96. package/ios/Tests/TrackSelectorTests.swift +168 -33
  97. package/ios/TrackPlayer.swift +57 -34
  98. package/ios/TrackSelector.swift +45 -30
  99. package/ios/Util/ArtworkImageFetcher.swift +16 -1
  100. package/ios/Util/NetworkMonitor.swift +43 -26
  101. package/ios/Util/SFSymbolRenderer.swift +24 -2
  102. package/ios/Util/SVGProcessor.swift +9 -2
  103. package/lib/commonjs/AudioBrowser.js +11 -0
  104. package/lib/commonjs/AudioBrowser.js.map +1 -1
  105. package/lib/commonjs/features/browser-config.js +11 -8
  106. package/lib/commonjs/features/browser-config.js.map +1 -1
  107. package/lib/commonjs/features/browser.js +2 -2
  108. package/lib/commonjs/features/favorites.js +13 -10
  109. package/lib/commonjs/features/favorites.js.map +1 -1
  110. package/lib/commonjs/features/playback/controls.js +4 -2
  111. package/lib/commonjs/features/playback/controls.js.map +1 -1
  112. package/lib/commonjs/features/playback/progress.js +2 -1
  113. package/lib/commonjs/features/playback/progress.js.map +1 -1
  114. package/lib/commonjs/features/playback/rate.js +8 -1
  115. package/lib/commonjs/features/playback/rate.js.map +1 -1
  116. package/lib/commonjs/features/playback/state.js +2 -3
  117. package/lib/commonjs/features/playback/state.js.map +1 -1
  118. package/lib/commonjs/features/player/options.js.map +1 -1
  119. package/lib/commonjs/features/player/setup.js +3 -1
  120. package/lib/commonjs/features/player/setup.js.map +1 -1
  121. package/lib/commonjs/features/player/validateOptions.js +2 -0
  122. package/lib/commonjs/features/player/validateOptions.js.map +1 -1
  123. package/lib/commonjs/features/queue/activeTrack.js +14 -3
  124. package/lib/commonjs/features/queue/activeTrack.js.map +1 -1
  125. package/lib/commonjs/features/queue/queue.js +11 -4
  126. package/lib/commonjs/features/queue/queue.js.map +1 -1
  127. package/lib/commonjs/utils/LazyNativeEmitter.js +21 -10
  128. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  129. package/lib/commonjs/utils/NativeUpdatedValue.js +31 -13
  130. package/lib/commonjs/utils/NativeUpdatedValue.js.map +1 -1
  131. package/lib/commonjs/utils/getTrackIdentity.js +24 -0
  132. package/lib/commonjs/utils/getTrackIdentity.js.map +1 -0
  133. package/lib/commonjs/utils/useNativeValueRefreshedBy.js +33 -0
  134. package/lib/commonjs/utils/useNativeValueRefreshedBy.js.map +1 -0
  135. package/lib/commonjs/web/NativeAudioBrowser.js +32 -24
  136. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  137. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  138. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  139. package/lib/commonjs/web/browser/BrowserManager.js +144 -87
  140. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  141. package/lib/commonjs/web/browser/FavoriteManager.js +30 -21
  142. package/lib/commonjs/web/browser/FavoriteManager.js.map +1 -1
  143. package/lib/commonjs/web/browser/assertBrowsePageShape.js +2 -2
  144. package/lib/commonjs/web/browser/assertBrowsePageShape.js.map +1 -1
  145. package/lib/commonjs/web/browser/classifyTrackNavigation.js +9 -9
  146. package/lib/commonjs/web/browser/classifyTrackNavigation.js.map +1 -1
  147. package/lib/commonjs/web/browser/sections.js +89 -0
  148. package/lib/commonjs/web/browser/sections.js.map +1 -0
  149. package/lib/commonjs/web/player/NowPlayingManager.js +4 -3
  150. package/lib/commonjs/web/player/NowPlayingManager.js.map +1 -1
  151. package/lib/commonjs/web/util/BrowserPathHelper.js +56 -11
  152. package/lib/commonjs/web/util/BrowserPathHelper.js.map +1 -1
  153. package/lib/module/AudioBrowser.js +1 -0
  154. package/lib/module/AudioBrowser.js.map +1 -1
  155. package/lib/module/features/browser-config.js +11 -8
  156. package/lib/module/features/browser-config.js.map +1 -1
  157. package/lib/module/features/browser.js +2 -2
  158. package/lib/module/features/favorites.js +13 -10
  159. package/lib/module/features/favorites.js.map +1 -1
  160. package/lib/module/features/playback/controls.js +4 -2
  161. package/lib/module/features/playback/controls.js.map +1 -1
  162. package/lib/module/features/playback/progress.js +2 -1
  163. package/lib/module/features/playback/progress.js.map +1 -1
  164. package/lib/module/features/playback/rate.js +8 -1
  165. package/lib/module/features/playback/rate.js.map +1 -1
  166. package/lib/module/features/playback/state.js +2 -3
  167. package/lib/module/features/playback/state.js.map +1 -1
  168. package/lib/module/features/player/options.js.map +1 -1
  169. package/lib/module/features/player/setup.js +3 -1
  170. package/lib/module/features/player/setup.js.map +1 -1
  171. package/lib/module/features/player/validateOptions.js +2 -0
  172. package/lib/module/features/player/validateOptions.js.map +1 -1
  173. package/lib/module/features/queue/activeTrack.js +14 -3
  174. package/lib/module/features/queue/activeTrack.js.map +1 -1
  175. package/lib/module/features/queue/queue.js +11 -4
  176. package/lib/module/features/queue/queue.js.map +1 -1
  177. package/lib/module/utils/LazyNativeEmitter.js +21 -10
  178. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  179. package/lib/module/utils/NativeUpdatedValue.js +31 -13
  180. package/lib/module/utils/NativeUpdatedValue.js.map +1 -1
  181. package/lib/module/utils/getTrackIdentity.js +20 -0
  182. package/lib/module/utils/getTrackIdentity.js.map +1 -0
  183. package/lib/module/utils/useNativeValueRefreshedBy.js +29 -0
  184. package/lib/module/utils/useNativeValueRefreshedBy.js.map +1 -0
  185. package/lib/module/web/NativeAudioBrowser.js +32 -24
  186. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  187. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  188. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  189. package/lib/module/web/browser/BrowserManager.js +144 -87
  190. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  191. package/lib/module/web/browser/FavoriteManager.js +31 -21
  192. package/lib/module/web/browser/FavoriteManager.js.map +1 -1
  193. package/lib/module/web/browser/assertBrowsePageShape.js +2 -2
  194. package/lib/module/web/browser/assertBrowsePageShape.js.map +1 -1
  195. package/lib/module/web/browser/classifyTrackNavigation.js +9 -9
  196. package/lib/module/web/browser/classifyTrackNavigation.js.map +1 -1
  197. package/lib/module/web/browser/sections.js +83 -0
  198. package/lib/module/web/browser/sections.js.map +1 -0
  199. package/lib/module/web/player/NowPlayingManager.js +4 -3
  200. package/lib/module/web/player/NowPlayingManager.js.map +1 -1
  201. package/lib/module/web/util/BrowserPathHelper.js +56 -11
  202. package/lib/module/web/util/BrowserPathHelper.js.map +1 -1
  203. package/lib/typescript/src/AudioBrowser.d.ts +1 -0
  204. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  205. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  206. package/lib/typescript/src/features/browser.d.ts +2 -2
  207. package/lib/typescript/src/features/favorites.d.ts +13 -10
  208. package/lib/typescript/src/features/favorites.d.ts.map +1 -1
  209. package/lib/typescript/src/features/playback/controls.d.ts +4 -2
  210. package/lib/typescript/src/features/playback/controls.d.ts.map +1 -1
  211. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  212. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  213. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  214. package/lib/typescript/src/features/playback/state.d.ts +2 -3
  215. package/lib/typescript/src/features/playback/state.d.ts.map +1 -1
  216. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts +1 -1
  217. package/lib/typescript/src/features/playback/trackPlaybackTime.d.ts.map +1 -1
  218. package/lib/typescript/src/features/player/options.d.ts +21 -19
  219. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  220. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  221. package/lib/typescript/src/features/player/validateOptions.d.ts +1 -8
  222. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -1
  223. package/lib/typescript/src/features/queue/activeTrack.d.ts +8 -1
  224. package/lib/typescript/src/features/queue/activeTrack.d.ts.map +1 -1
  225. package/lib/typescript/src/features/queue/queue.d.ts +5 -2
  226. package/lib/typescript/src/features/queue/queue.d.ts.map +1 -1
  227. package/lib/typescript/src/types/browser-native.d.ts +2 -2
  228. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  229. package/lib/typescript/src/types/browser-nodes.d.ts +361 -126
  230. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  231. package/lib/typescript/src/types/browser.d.ts +33 -53
  232. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  233. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +16 -17
  234. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  235. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts +24 -19
  236. package/lib/typescript/src/utils/NativeUpdatedValue.d.ts.map +1 -1
  237. package/lib/typescript/src/utils/getTrackIdentity.d.ts +18 -0
  238. package/lib/typescript/src/utils/getTrackIdentity.d.ts.map +1 -0
  239. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts +21 -0
  240. package/lib/typescript/src/utils/useNativeValueRefreshedBy.d.ts.map +1 -0
  241. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +0 -1
  242. package/nitrogen/generated/android/c++/{JFavoritesMatchMode.hpp → JArtworkRendering.hpp} +15 -15
  243. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +8 -3
  244. package/nitrogen/generated/android/c++/JBrowseResult.hpp +16 -4
  245. package/nitrogen/generated/android/c++/JCarPlayPlayingIndicatorLocation.hpp +58 -0
  246. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  247. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +8 -3
  248. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +8 -3
  249. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +8 -3
  250. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +8 -3
  251. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +8 -3
  252. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +8 -3
  253. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +8 -2
  254. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +8 -2
  255. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +16 -4
  256. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +8 -3
  257. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +2 -5
  258. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +8 -3
  259. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +15 -3
  260. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +8 -2
  261. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  262. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +33 -14
  263. package/nitrogen/generated/android/c++/JIOSOptions.hpp +9 -3
  264. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  265. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +8 -3
  266. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +22 -10
  267. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +14 -2
  268. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +10 -7
  269. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +2 -5
  270. package/nitrogen/generated/android/c++/JNitroIOSUpdateOptions.hpp +9 -3
  271. package/nitrogen/generated/android/c++/JOptions.hpp +2 -5
  272. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +8 -3
  273. package/nitrogen/generated/android/c++/JPlayerCapabilities.hpp +5 -11
  274. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +55 -51
  275. package/nitrogen/generated/android/c++/JSection.hpp +120 -0
  276. package/nitrogen/generated/android/c++/JSectionStyle.hpp +96 -0
  277. package/nitrogen/generated/android/c++/JStyleDisplay.hpp +58 -0
  278. package/nitrogen/generated/android/c++/JTrack.hpp +23 -48
  279. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +8 -2
  280. package/nitrogen/generated/android/c++/JTrackStyle.hpp +46 -18
  281. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +16 -4
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoritesMatchMode.kt → ArtworkRendering.kt} +5 -5
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CarPlayPlayingIndicatorLocation.kt +23 -0
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/IOSOptions.kt +6 -3
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +5 -5
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroIOSUpdateOptions.kt +6 -3
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/PlayerCapabilities.kt +2 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ResolvedTrack.kt +12 -18
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{FavoriteConfig.kt → Section.kt} +18 -6
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +59 -0
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/StyleDisplay.kt +23 -0
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Track.kt +6 -15
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +35 -5
  297. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +158 -82
  298. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +25 -10
  299. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +25 -10
  300. package/nitrogen/generated/ios/swift/ArtworkRendering.swift +40 -0
  301. package/nitrogen/generated/ios/swift/CarPlayPlayingIndicatorLocation.swift +40 -0
  302. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  303. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  304. package/nitrogen/generated/ios/swift/IOSOptions.swift +7 -2
  305. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  306. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +5 -5
  307. package/nitrogen/generated/ios/swift/NitroIOSUpdateOptions.swift +12 -1
  308. package/nitrogen/generated/ios/swift/PlayerCapabilities.swift +8 -27
  309. package/nitrogen/generated/ios/swift/ResolvedTrack.swift +54 -83
  310. package/nitrogen/generated/ios/swift/Section.swift +100 -0
  311. package/nitrogen/generated/ios/swift/SectionStyle.swift +133 -0
  312. package/nitrogen/generated/ios/swift/StyleDisplay.swift +40 -0
  313. package/nitrogen/generated/ios/swift/Track.swift +20 -73
  314. package/nitrogen/generated/ios/swift/TrackStyle.swift +86 -22
  315. package/nitrogen/generated/shared/c++/{FavoritesMatchMode.hpp → ArtworkRendering.hpp} +17 -17
  316. package/nitrogen/generated/shared/c++/CarPlayPlayingIndicatorLocation.hpp +76 -0
  317. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  318. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  319. package/nitrogen/generated/shared/c++/IOSOptions.hpp +9 -2
  320. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  321. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -6
  322. package/nitrogen/generated/shared/c++/NitroIOSUpdateOptions.hpp +9 -2
  323. package/nitrogen/generated/shared/c++/PlayerCapabilities.hpp +6 -9
  324. package/nitrogen/generated/shared/c++/ResolvedTrack.hpp +30 -38
  325. package/nitrogen/generated/shared/c++/Section.hpp +106 -0
  326. package/nitrogen/generated/shared/c++/SectionStyle.hpp +126 -0
  327. package/nitrogen/generated/shared/c++/StyleDisplay.hpp +76 -0
  328. package/nitrogen/generated/shared/c++/Track.hpp +15 -31
  329. package/nitrogen/generated/shared/c++/TrackStyle.hpp +73 -34
  330. package/package.json +7 -2
  331. package/src/AudioBrowser.ts +1 -0
  332. package/src/features/browser-config.test.ts +21 -6
  333. package/src/features/browser-config.ts +11 -8
  334. package/src/features/browser.test.ts +1 -1
  335. package/src/features/browser.ts +2 -2
  336. package/src/features/favorites.ts +13 -10
  337. package/src/features/hook-wiring.test.ts +63 -15
  338. package/src/features/playback/controls.ts +4 -2
  339. package/src/features/playback/progress.ts +2 -1
  340. package/src/features/playback/rate.ts +8 -1
  341. package/src/features/playback/state.ts +2 -3
  342. package/src/features/playback/trackPlaybackTime.ts +1 -1
  343. package/src/features/player/options.ts +27 -19
  344. package/src/features/player/setup.test.ts +12 -3
  345. package/src/features/player/setup.ts +3 -1
  346. package/src/features/player/validateOptions.ts +2 -0
  347. package/src/features/queue/activeTrack.ts +14 -3
  348. package/src/features/queue/queue.ts +11 -4
  349. package/src/nitro-stub-drift.test.ts +40 -0
  350. package/src/public-surface.test.ts +40 -0
  351. package/src/specs/audio-browser.nitro.ts +4 -3
  352. package/src/style-resolution.test.ts +210 -0
  353. package/src/types/browser-native.ts +2 -2
  354. package/src/types/browser-nodes.ts +377 -135
  355. package/src/types/browser.ts +34 -55
  356. package/src/utils/LazyNativeEmitter.ts +20 -10
  357. package/src/utils/NativeUpdatedValue.ts +29 -13
  358. package/src/utils/getTrackIdentity.ts +20 -0
  359. package/src/utils/useNativeValueRefreshedBy.ts +38 -0
  360. package/src/web/NativeAudioBrowser.ts +37 -22
  361. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  362. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  363. package/src/web/browser/BrowserManager.ts +176 -95
  364. package/src/web/browser/FavoriteManager.test.ts +84 -0
  365. package/src/web/browser/FavoriteManager.ts +28 -23
  366. package/src/web/browser/assertBrowsePageShape.test.ts +1 -1
  367. package/src/web/browser/assertBrowsePageShape.ts +5 -3
  368. package/src/web/browser/classifyTrackNavigation.test.ts +10 -8
  369. package/src/web/browser/classifyTrackNavigation.ts +9 -9
  370. package/src/web/browser/parseSearchResponse.test.ts +8 -2
  371. package/src/web/browser/sections.test.ts +91 -0
  372. package/src/web/browser/sections.ts +72 -0
  373. package/src/web/player/NowPlayingManager.ts +4 -3
  374. package/src/web/util/BrowserPathHelper.test.ts +52 -0
  375. package/src/web/util/BrowserPathHelper.ts +61 -13
  376. package/website/guide/android-auto.md +114 -0
  377. package/website/guide/android-certificates.md +409 -0
  378. package/website/guide/artwork.md +255 -0
  379. package/website/guide/audio-output.md +135 -0
  380. package/website/guide/automotive.md +86 -0
  381. package/website/guide/basic-usage.md +151 -0
  382. package/website/guide/battery.md +115 -0
  383. package/website/guide/browser.md +581 -0
  384. package/website/guide/carplay.md +620 -0
  385. package/website/guide/configuration.md +173 -0
  386. package/website/guide/equalizer.md +231 -0
  387. package/website/guide/errors.md +310 -0
  388. package/website/guide/favorites.md +154 -0
  389. package/website/guide/gate.md +143 -0
  390. package/website/guide/getting-started.md +46 -0
  391. package/website/guide/hooks.md +238 -0
  392. package/website/guide/live-streams.md +125 -0
  393. package/website/guide/metadata.md +239 -0
  394. package/website/guide/migrating-from-track-player.md +244 -0
  395. package/website/guide/native-callback-fetch.md +127 -0
  396. package/website/guide/network.md +86 -0
  397. package/website/guide/now-playing.md +175 -0
  398. package/website/guide/playback.md +328 -0
  399. package/website/guide/queue.md +300 -0
  400. package/website/guide/remote-controls.md +256 -0
  401. package/website/guide/search.md +255 -0
  402. package/website/guide/sleep-timer.md +268 -0
  403. package/website/guide/track.md +246 -0
  404. package/ios/CarPlay/ImageRowItem+CarPlay.swift +0 -19
  405. package/nitrogen/generated/android/c++/JFavoriteConfig.hpp +0 -58
  406. package/nitrogen/generated/android/c++/JImageRowItem.hpp +0 -105
  407. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.cpp +0 -26
  408. package/nitrogen/generated/android/c++/JVariant_Boolean_FavoriteConfig.hpp +0 -71
  409. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageRowItem.kt +0 -68
  410. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Variant_Boolean_FavoriteConfig.kt +0 -53
  411. package/nitrogen/generated/ios/swift/FavoriteConfig.swift +0 -29
  412. package/nitrogen/generated/ios/swift/FavoritesMatchMode.swift +0 -40
  413. package/nitrogen/generated/ios/swift/ImageRowItem.swift +0 -195
  414. package/nitrogen/generated/ios/swift/Variant_Bool_FavoriteConfig.swift +0 -18
  415. package/nitrogen/generated/shared/c++/FavoriteConfig.hpp +0 -84
  416. package/nitrogen/generated/shared/c++/ImageRowItem.hpp +0 -129
@@ -38,19 +38,28 @@ final class MediaLoader {
38
38
  func resolveAndLoad(src: String, track: Track? = nil) {
39
39
  if let resolver = mediaUrlResolver {
40
40
  mediaResolverTask?.cancel()
41
- mediaResolverTask = Task {
42
- self.logger.debug("resolveAndLoad: starting resolution for \(src)")
41
+ // `[weak self]` like the sibling load tasks below: the resolver is
42
+ // consumer-supplied and `cancelAll()` cannot force an in-flight `await`
43
+ // to return, so a strong capture let a resolver that never settles pin
44
+ // this loader (which also retains the task) indefinitely.
45
+ // `Logger` is a value type, so capturing it directly keeps the logging
46
+ // off `self` — and `self` weak, so a consumer resolver that never settles
47
+ // can't pin the loader (`cancelAll()` can't force an in-flight `await` to
48
+ // return).
49
+ let logger = logger
50
+ mediaResolverTask = Task { [weak self] in
51
+ logger.debug("resolveAndLoad: starting resolution for \(src)")
43
52
 
44
53
  guard !Task.isCancelled else {
45
- self.logger.debug("resolveAndLoad: cancelled before start")
54
+ logger.debug("resolveAndLoad: cancelled before start")
46
55
  return
47
56
  }
48
57
 
49
- self.logger.debug("resolveAndLoad: calling resolver...")
58
+ logger.debug("resolveAndLoad: calling resolver...")
50
59
  let resolved = await resolver(src, track)
51
60
 
52
- guard !Task.isCancelled else {
53
- self.logger.debug("resolveAndLoad: cancelled after resolver returned")
61
+ guard !Task.isCancelled, let self else {
62
+ logger.debug("resolveAndLoad: cancelled after resolver returned")
54
63
  return
55
64
  }
56
65
 
@@ -96,7 +105,7 @@ final class MediaLoader {
96
105
  .availableMetadataFormats,
97
106
  ) else { return }
98
107
 
99
- guard !Task.isCancelled, pendingAsset == asset else { return }
108
+ guard isCurrent(pendingAsset) else { return }
100
109
 
101
110
  if !commonMetadata.isEmpty {
102
111
  delegate?.mediaLoaderDidReceiveCommonMetadata(commonMetadata)
@@ -104,19 +113,21 @@ final class MediaLoader {
104
113
 
105
114
  if !chapterLocales.isEmpty {
106
115
  for locale in chapterLocales {
107
- guard !Task.isCancelled else { return }
116
+ guard isCurrent(pendingAsset) else { return }
108
117
  if let chapters = try? await pendingAsset.loadChapterMetadataGroups(
109
118
  withTitleLocale: locale,
110
119
  containingItemsWithCommonKeys: [],
111
120
  ) {
121
+ guard isCurrent(pendingAsset) else { return }
112
122
  delegate?.mediaLoaderDidReceiveChapterMetadata(chapters)
113
123
  }
114
124
  }
115
125
  } else {
116
126
  let duration = await (try? pendingAsset.load(.duration)) ?? .zero
117
127
  for format in metadataFormats {
118
- guard !Task.isCancelled else { return }
128
+ guard isCurrent(pendingAsset) else { return }
119
129
  if let metadata = try? await pendingAsset.loadMetadata(for: format) {
130
+ guard isCurrent(pendingAsset) else { return }
120
131
  let timeRange = CMTimeRange(
121
132
  start: CMTime(seconds: 0, preferredTimescale: 1000),
122
133
  end: duration,
@@ -159,6 +170,19 @@ final class MediaLoader {
159
170
  }
160
171
  }
161
172
 
173
+ /// Whether `candidate` is still the load this loader cares about — checked
174
+ /// after EVERY await, not just once before a loop.
175
+ ///
176
+ /// Cancellation alone is not enough: it only sets a flag, and can't abort a
177
+ /// load already in flight inside AVFoundation (only `cancelLoading()` does,
178
+ /// and the track-change path deliberately doesn't call it — the old asset
179
+ /// still backs the playing item at that point). So a load that lands after
180
+ /// the track changed would otherwise attribute the old asset's metadata to
181
+ /// the newly loaded track.
182
+ private func isCurrent(_ candidate: AVURLAsset) -> Bool {
183
+ !Task.isCancelled && candidate == asset
184
+ }
185
+
162
186
  func cancelAll() {
163
187
  mediaResolverTask?.cancel()
164
188
  mediaResolverTask = nil
@@ -30,17 +30,51 @@ final class NowPlayingUpdater {
30
30
  let album: String?
31
31
  }
32
32
 
33
+ /// Dedupe baseline for `applyFields`. Only meaningful while it describes the
34
+ /// controller's current info dict, so the two are cleared together in
35
+ /// `clearNowPlaying()` — never separately.
33
36
  private var lastPublished: Published?
34
37
 
38
+ /// Empties the now-playing surface and the dedupe baseline that describes it.
39
+ /// One call, because clearing the dict without the baseline makes the next
40
+ /// publish of the SAME track dedupe against a dict that no longer holds it —
41
+ /// artwork lands (never deduped) but the text doesn't, and `onChanged`
42
+ /// never fires.
43
+ func clearNowPlaying() {
44
+ nowPlayingInfoController.clear()
45
+ lastPublished = nil
46
+ formattedTrackId = nil
47
+ }
48
+
35
49
  /// Bumped on every `render`; an async formatter result applies only if its render is still the
36
50
  /// latest (latest-render-wins — drops a result whose track skipped or whose state moved on while
37
51
  /// the formatter was in flight).
38
52
  private var renderGeneration: UInt = 0
39
53
 
54
+ /// The track whose *formatted* line is currently on the surface. While this
55
+ /// matches the rendering track, `render` skips its interim default write — see
56
+ /// the comment at that site. Cleared whenever the surface stops showing a
57
+ /// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
58
+ private var formattedTrackId: String?
59
+
40
60
  init(nowPlayingInfoController: NowPlayingInfoController) {
41
61
  self.nowPlayingInfoController = nowPlayingInfoController
42
62
  }
43
63
 
64
+ /// Pixel width to request now-playing artwork at: the active scene's screen
65
+ /// width in pixels, capped at `maxArtworkPixelWidth`. With no foreground
66
+ /// scene (headless CarPlay) the cap stands in — every current device exceeds
67
+ /// it anyway, and this only sizes a CDN request.
68
+ private static var artworkPixelWidth: CGFloat {
69
+ guard let screen = UIApplication.shared.activeWindowScene?.screen else {
70
+ return maxArtworkPixelWidth
71
+ }
72
+ return min(screen.bounds.width * screen.scale, maxArtworkPixelWidth)
73
+ }
74
+
75
+ /// Mirrored by the Kotlin twin's `artworkSizePx`.
76
+ private static let maxArtworkPixelWidth: CGFloat = 1200
77
+
44
78
  /// Publishes the static, per-track fields + artwork on a track change. The title/artist/album
45
79
  /// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
46
80
  /// renders).
@@ -75,6 +109,8 @@ final class NowPlayingUpdater {
75
109
  let defaultSecondary = override?.artist ?? track.artist
76
110
  let defaultAlbum = override?.album ?? track.album
77
111
 
112
+ let trackId = track.identity ?? track.path
113
+
78
114
  if let flash {
79
115
  applyFields(
80
116
  track: track,
@@ -82,10 +118,22 @@ final class NowPlayingUpdater {
82
118
  artist: flash.artist ?? defaultSecondary,
83
119
  album: flash.album ?? defaultAlbum,
84
120
  )
121
+ // The flash replaced the formatter's line, so the surface no longer shows
122
+ // one: the render that follows the flash reverting must publish the default
123
+ // immediately rather than sit on stale flash text awaiting the formatter.
124
+ formattedTrackId = nil
85
125
  return
86
126
  }
87
127
 
88
- applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
128
+ // Publish the raw default only while no formatter result is on screen for this
129
+ // track. `render` runs on EVERY playback transition (state change, playing/
130
+ // buffering flip, timed-metadata tick, connectivity change); writing the
131
+ // default each time would flash the track title over the formatter's line
132
+ // until the async result lands, since the two differ by construction on a live
133
+ // stream (station name vs. song line).
134
+ if formatter == nil || formattedTrackId != trackId {
135
+ applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
136
+ }
89
137
 
90
138
  guard let formatter else { return }
91
139
  let params = FormatNowPlayingParams(
@@ -111,6 +159,9 @@ final class NowPlayingUpdater {
111
159
  artist: formatted.artist ?? defaultSecondary,
112
160
  album: formatted.album ?? defaultAlbum,
113
161
  )
162
+ // A formatter result now owns the surface for this track, so later
163
+ // renders skip the interim default write.
164
+ self.formattedTrackId = trackId
114
165
  }
115
166
  }
116
167
  .catch { @Sendable [weak self] error in
@@ -130,7 +181,7 @@ final class NowPlayingUpdater {
130
181
  let resolvedArtist = artist ?? track.artist
131
182
  let resolvedAlbum = album ?? track.album
132
183
  let published = Published(
133
- trackId: track.src ?? track.url,
184
+ trackId: track.identity ?? track.path,
134
185
  title: resolvedTitle,
135
186
  artist: resolvedArtist,
136
187
  album: resolvedAlbum,
@@ -152,10 +203,7 @@ final class NowPlayingUpdater {
152
203
  let artworkUrl = track.artworkSource?.uri ?? track.artwork?.url
153
204
  logger.debug("loadArtwork: \(track.title), artworkUrl: \(artworkUrl ?? "nil")")
154
205
 
155
- // Now Playing artwork: use screen width in pixels, capped at 1200px
156
- let screenScale = UIScreen.main.scale
157
- let screenWidth = UIScreen.main.bounds.width * screenScale
158
- let artworkSize = min(screenWidth, 1200)
206
+ let artworkSize = Self.artworkPixelWidth
159
207
  let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
160
208
 
161
209
  artworkLoadTask?.cancel()
@@ -526,14 +526,21 @@ class PlaybackCoordinator {
526
526
  let changed = queue.add([track], initialIndex: 0)
527
527
  if changed { handleCurrentTrackChanged() }
528
528
  } else {
529
- queue.replace(queue.currentIndex, track)
530
- handleCurrentTrackChanged()
529
+ // `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
530
+ // above, so this cannot fail — but surface it rather than reloading the
531
+ // old track if that invariant is ever broken.
532
+ do {
533
+ try queue.replace(queue.currentIndex, track)
534
+ handleCurrentTrackChanged()
535
+ } catch {
536
+ logger.error("load: replace at \(self.queue.currentIndex) failed: \(error)")
537
+ }
531
538
  }
532
539
  }
533
540
  }
534
541
 
535
- func replace(_ index: Int, _ track: Track) {
536
- queue.replace(index, track)
542
+ func replace(_ index: Int, _ track: Track) throws {
543
+ try queue.replace(index, track)
537
544
  }
538
545
 
539
546
  func setQueue(
@@ -691,14 +698,14 @@ class PlaybackCoordinator {
691
698
  guard let src = currentTrack.src else {
692
699
  logger.error("Failed to load track - no src")
693
700
  logger.error(" track.title: \(currentTrack.title)")
694
- logger.error(" track.url: \(currentTrack.url ?? "nil")")
701
+ logger.error(" track.path: \(currentTrack.path ?? "nil")")
695
702
  effectHandler?.clearCurrentItem()
696
703
  transition(.errorOccurred(.invalidSourceUrl("nil")))
697
704
  return
698
705
  }
699
706
 
700
707
  logger.debug("Loading track: \(currentTrack.title)")
701
- logger.debug(" track.url: \(currentTrack.url ?? "nil")")
708
+ logger.debug(" track.path: \(currentTrack.path ?? "nil")")
702
709
  logger.debug(" track.src: \(src)")
703
710
 
704
711
  effectHandler?.loadTrack(src: src, track: currentTrack)
@@ -48,10 +48,16 @@ final class PlaybackTimer {
48
48
 
49
49
  private func start(_ interval: TimeInterval) {
50
50
  guard timer == nil else { return }
51
- timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) {
52
- [weak self] _ in
51
+ // `.common`, not the `.default` mode `Timer.scheduledTimer` installs: UIKit
52
+ // runs the main run loop in tracking mode while a list is being dragged, and
53
+ // a default-mode timer does not fire there. Missed repeats are dropped rather
54
+ // than replayed, so progress events would freeze and interval ticks would be
55
+ // lost for as long as the user scrolls. (VolumeFader already does this.)
56
+ let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
53
57
  MainActor.assumeIsolated { self?.onTick() }
54
58
  }
59
+ self.timer = timer
60
+ RunLoop.main.add(timer, forMode: .common)
55
61
  }
56
62
 
57
63
  private func stop() {
@@ -225,11 +225,8 @@ class QueueManager {
225
225
  let wasEmpty = tracks.isEmpty
226
226
  let insertIndex = tracks.count
227
227
  tracks.append(contentsOf: newTracks)
228
+ if wasEmpty { return adoptInitialCurrent(initialIndex) }
228
229
  shuffleOrder.insert(at: insertIndex, count: newTracks.count)
229
- if wasEmpty {
230
- currentIndex = max(0, min(initialIndex, tracks.count - 1))
231
- return true
232
- }
233
230
  return false
234
231
  }
235
232
 
@@ -250,14 +247,31 @@ class QueueManager {
250
247
  currentIndex += newTracks.count
251
248
  }
252
249
  tracks.insert(contentsOf: newTracks, at: index)
250
+ if wasEmpty { return adoptInitialCurrent(0) }
253
251
  shuffleOrder.insert(at: index, count: newTracks.count)
254
- if wasEmpty {
255
- currentIndex = 0
256
- return true
257
- }
258
252
  return false
259
253
  }
260
254
 
255
+ /// The queue was empty: `index` becomes the current track AND leads the
256
+ /// shuffle order. One operation because the two must not be separable —
257
+ /// forgetting the second half is the bug this exists to prevent.
258
+ ///
259
+ /// `insert(at:count:)` drops new indices at *random* shuffle positions, so
260
+ /// without the pin the starting track can land last in the order, which reads
261
+ /// as `isLastInPlaybackOrder`: the queue "ends" (and fires
262
+ /// `playerDidEndQueue`) the moment the first track finishes. Pins
263
+ /// unconditionally, like `setQueue` — the order has to be sane whether or not
264
+ /// shuffle is on right now, since `shuffleEnabled`'s didSet only re-pins on
265
+ /// the off→on edge.
266
+ ///
267
+ /// Returns true, so callers can `return adoptInitialCurrent(…)` to report the
268
+ /// current track changed.
269
+ private func adoptInitialCurrent(_ index: Int) -> Bool {
270
+ currentIndex = max(0, min(index, tracks.count - 1))
271
+ shuffleOrder = ShuffleOrder(length: tracks.count, firstIndex: currentIndex)
272
+ return true
273
+ }
274
+
261
275
  /// Remove a track from the queue.
262
276
  /// Returns `true` if the current track changed.
263
277
  @discardableResult
@@ -288,8 +302,18 @@ class QueueManager {
288
302
  let insertion = min(newTracks.count, toIndex)
289
303
  newTracks.insert(track, at: insertion)
290
304
  tracks = newTracks
305
+ // Carry the moved track's shuffle position across the move. `insert(at:count:)`
306
+ // would drop it at a RANDOM position instead — reordering the queue would then
307
+ // silently reorder shuffled playback, and moving the playing track could land it
308
+ // last in the order, ending the queue at the next track boundary with tracks
309
+ // still unplayed (the failure `adoptInitialCurrent` guards against elsewhere).
310
+ // `remove` shifts every later position down by one, so re-inserting at the
311
+ // captured position restores the exact prior order.
312
+ let shufflePosition = shuffleOrder.shufflePosition(of: fromIndex)
291
313
  shuffleOrder.remove(from: fromIndex, to: fromIndex + 1)
292
- shuffleOrder.insert(at: insertion, count: 1)
314
+ shuffleOrder.insert(
315
+ at: insertion, atShufflePosition: shufflePosition ?? shuffleOrder.count,
316
+ )
293
317
  // The pointer follows the playing track; its identity never changes on a
294
318
  // move, so no caller needs to reload (returning true reloads).
295
319
  if currentIndex == fromIndex {
@@ -317,7 +341,9 @@ class QueueManager {
317
341
  // MARK: - Other Mutations
318
342
 
319
343
  /// Replace the track at a specific index.
320
- func replace(_ index: Int, _ track: Track) {
344
+ /// Throws like every other index-taking mutation here, rather than trapping.
345
+ func replace(_ index: Int, _ track: Track) throws {
346
+ try throwIfIndexInvalid(index: index)
321
347
  tracks[index] = track
322
348
  }
323
349
 
@@ -123,9 +123,10 @@ class RetryManager {
123
123
 
124
124
  // MARK: - Error Classification
125
125
 
126
- /// HTTP statuses worth another attempt. Same set as Android's
127
- /// `RetryLoadErrorHandlingPolicy.RETRYABLE_HTTP_STATUS_CODES`, so a flaky
128
- /// server behaves the same on both platforms.
126
+ /// HTTP statuses worth another attempt, beyond the 500–599 range checked in
127
+ /// `isRetryable`. Android's `RetryLoadErrorHandlingPolicy` shares 408/429 but
128
+ /// enumerates only 500/502/503/504 of the 5xx range, so uncommon 5xx codes
129
+ /// (501, 505…) retry here and not there.
129
130
  private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
130
131
 
131
132
  /// AVFoundation failures a retry cannot fix: the media is unusable rather
@@ -101,6 +101,13 @@ struct ShuffleOrder {
101
101
  return indexInShuffled[index] == 0
102
102
  }
103
103
 
104
+ /// The item's position in the shuffle order, or nil when it has none.
105
+ func shufflePosition(of index: Int) -> Int? {
106
+ guard index >= 0, index < indexInShuffled.count else { return nil }
107
+ let position = indexInShuffled[index]
108
+ return position >= 0 ? position : nil
109
+ }
110
+
104
111
  // MARK: - Mutation
105
112
 
106
113
  /// Inserts items at random positions in the shuffle order
@@ -125,6 +132,22 @@ struct ShuffleOrder {
125
132
  indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
126
133
  }
127
134
 
135
+ /// Inserts one item at `insertionIndex`, placing it at an explicit shuffle
136
+ /// position rather than a random one.
137
+ ///
138
+ /// Used by a queue `move`, which permutes positions but not identities: the
139
+ /// item already had a place in the playback order, and re-randomising it
140
+ /// silently reorders playback. For the *playing* track it can drop it at the
141
+ /// end of the order, which then reads as `isLastInPlaybackOrder` — the queue
142
+ /// reports itself over at the next track end, with tracks still unplayed.
143
+ mutating func insert(at insertionIndex: Int, atShufflePosition position: Int) {
144
+ for i in 0 ..< shuffled.count where shuffled[i] >= insertionIndex {
145
+ shuffled[i] += 1
146
+ }
147
+ shuffled.insert(insertionIndex, at: min(max(0, position), shuffled.count))
148
+ indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
149
+ }
150
+
128
151
  /// Removes items in the specified range from the shuffle order
129
152
  mutating func remove(from indexFrom: Int, to indexToExclusive: Int) {
130
153
  let removeCount = indexToExclusive - indexFrom
@@ -12,4 +12,17 @@ import Foundation
12
12
  @discardableResult func clear() -> Bool
13
13
  func onTrackChanged()
14
14
  func onTrackPlayedToEnd()
15
+
16
+ // Consumer-facing surface — HybridAudioBrowser drives these through
17
+ // TrackPlayer, so they belong here rather than behind a concrete downcast.
18
+
19
+ /// Invoked when the timer state changes (set, cleared, completed).
20
+ var onChanged: ((SleepTimerState) -> Void)? { get set }
21
+ /// The current timer state, or nil when no timer is active.
22
+ func get() -> SleepTimerState?
23
+ /// Sets a time-based timer, optionally fading out over the last
24
+ /// `fadeDuration` seconds.
25
+ func set(seconds: TimeInterval, fadeDuration: TimeInterval?)
26
+ /// Sets the timer to stop playback when the current track ends.
27
+ func setToEndOfTrack()
15
28
  }
@@ -281,24 +281,59 @@ class RemoteCommandController {
281
281
 
282
282
  // MARK: - Handlers
283
283
 
284
- lazy var handlePlayCommand: RemoteCommandHandler = handlePlayCommandDefault
285
- lazy var handlePauseCommand: RemoteCommandHandler = handlePauseCommandDefault
286
- lazy var handleStopCommand: RemoteCommandHandler = handleStopCommandDefault
287
- lazy var handleTogglePlayPauseCommand: RemoteCommandHandler =
288
- handleTogglePlayPauseCommandDefault
289
- lazy var handleSkipForwardCommand: RemoteCommandHandler = handleSkipForwardCommandDefault
290
- lazy var handleSkipBackwardCommand: RemoteCommandHandler = handleSkipBackwardDefault
291
- lazy var handleChangePlaybackPositionCommand: RemoteCommandHandler =
292
- handleChangePlaybackPositionCommandDefault
293
- lazy var handleNextTrackCommand: RemoteCommandHandler = handleNextTrackCommandDefault
294
- lazy var handlePreviousTrackCommand: RemoteCommandHandler =
295
- handlePreviousTrackCommandDefault
296
- lazy var handleChangeRepeatModeCommand: RemoteCommandHandler =
297
- handleChangeRepeatModeCommandDefault
298
- lazy var handleChangeShuffleModeCommand: RemoteCommandHandler =
299
- handleChangeShuffleModeCommandDefault
300
- lazy var handleChangePlaybackRateCommand: RemoteCommandHandler =
301
- handleChangePlaybackRateCommandDefault
284
+ // Each default is wrapped in a `[weak self]` closure rather than assigned as a
285
+ // bare method reference (`= handlePlayCommandDefault`): an unapplied instance
286
+ // method curries to `{ [self] in self.method($0) }`, which strongly captures
287
+ // self. Stored back onto self, that is a permanent cycle — `enable(command:)`
288
+ // forces every lazy init, so the controller would never deallocate, leaking one
289
+ // per player instance across JS reloads and re-setups.
290
+ lazy var handlePlayCommand: RemoteCommandHandler = { [weak self] event in
291
+ self?.handlePlayCommandDefault(event: event) ?? .commandFailed
292
+ }
293
+
294
+ lazy var handlePauseCommand: RemoteCommandHandler = { [weak self] event in
295
+ self?.handlePauseCommandDefault(event: event) ?? .commandFailed
296
+ }
297
+
298
+ lazy var handleStopCommand: RemoteCommandHandler = { [weak self] event in
299
+ self?.handleStopCommandDefault(event: event) ?? .commandFailed
300
+ }
301
+
302
+ lazy var handleTogglePlayPauseCommand: RemoteCommandHandler = { [weak self] event in
303
+ self?.handleTogglePlayPauseCommandDefault(event: event) ?? .commandFailed
304
+ }
305
+
306
+ lazy var handleSkipForwardCommand: RemoteCommandHandler = { [weak self] event in
307
+ self?.handleSkipForwardCommandDefault(event: event) ?? .commandFailed
308
+ }
309
+
310
+ lazy var handleSkipBackwardCommand: RemoteCommandHandler = { [weak self] event in
311
+ self?.handleSkipBackwardDefault(event: event) ?? .commandFailed
312
+ }
313
+
314
+ lazy var handleChangePlaybackPositionCommand: RemoteCommandHandler = { [weak self] event in
315
+ self?.handleChangePlaybackPositionCommandDefault(event: event) ?? .commandFailed
316
+ }
317
+
318
+ lazy var handleNextTrackCommand: RemoteCommandHandler = { [weak self] event in
319
+ self?.handleNextTrackCommandDefault(event: event) ?? .commandFailed
320
+ }
321
+
322
+ lazy var handlePreviousTrackCommand: RemoteCommandHandler = { [weak self] event in
323
+ self?.handlePreviousTrackCommandDefault(event: event) ?? .commandFailed
324
+ }
325
+
326
+ lazy var handleChangeRepeatModeCommand: RemoteCommandHandler = { [weak self] event in
327
+ self?.handleChangeRepeatModeCommandDefault(event: event) ?? .commandFailed
328
+ }
329
+
330
+ lazy var handleChangeShuffleModeCommand: RemoteCommandHandler = { [weak self] event in
331
+ self?.handleChangeShuffleModeCommandDefault(event: event) ?? .commandFailed
332
+ }
333
+
334
+ lazy var handleChangePlaybackRateCommand: RemoteCommandHandler = { [weak self] event in
335
+ self?.handleChangePlaybackRateCommandDefault(event: event) ?? .commandFailed
336
+ }
302
337
 
303
338
  private func handlePlayCommandDefault(event _: MPRemoteCommandEvent)
304
339
  -> MPRemoteCommandHandlerStatus
@@ -74,6 +74,56 @@ import Testing
74
74
  #expect(result == "/items?a=1&m=2&z=3")
75
75
  }
76
76
 
77
+ // MARK: - build
78
+
79
+ @Test func buildAppendsTrackIdWithQuestionMark() {
80
+ let result = BrowserPathHelper.build(parentPath: "/library", trackId: "song.mp3")
81
+ #expect(result == "/library?__trackId=song.mp3")
82
+ }
83
+
84
+ @Test func buildAppendsTrackIdWithAmpersandWhenParentHasQuery() {
85
+ let result = BrowserPathHelper.build(parentPath: "/search?q=jazz", trackId: "song.mp3")
86
+ #expect(result == "/search?q=jazz&__trackId=song.mp3")
87
+ }
88
+
89
+ @Test func buildRoundTripsSrcWithQueryParams() {
90
+ // A src carrying its own query string (signed CDN URL) must survive the
91
+ // build → extract/strip round-trip: an unescaped `&` splits the src into
92
+ // stray query params, truncating the trackId and polluting the parent path.
93
+ let src = "https://cdn.example.com/stream.mp3?token=abc&exp=1699999999"
94
+ let url = BrowserPathHelper.build(parentPath: "/library", trackId: src)
95
+ #expect(BrowserPathHelper.extractTrackId(url) == src)
96
+ #expect(BrowserPathHelper.stripTrackId(url) == "/library")
97
+ }
98
+
99
+ @Test func buildRoundTripsSrcWithEqualsAndPlus() {
100
+ let src = "https://cdn.example.com/a+b.mp3?sig=x=y"
101
+ let url = BrowserPathHelper.build(parentPath: "/library", trackId: src)
102
+ #expect(BrowserPathHelper.extractTrackId(url) == src)
103
+ #expect(BrowserPathHelper.stripTrackId(url) == "/library")
104
+ }
105
+
106
+ @Test func buildStampsTheIndexAndRoundTripsIt() {
107
+ let src = "https://cdn.example.com/stream.mp3?token=abc&exp=1"
108
+ let url = BrowserPathHelper.build(parentPath: "/library", trackId: src, index: 3)
109
+ #expect(BrowserPathHelper.extractTrackId(url) == src)
110
+ #expect(BrowserPathHelper.extractIndex(url) == 3)
111
+ #expect(BrowserPathHelper.stripTrackId(url) == "/library")
112
+ }
113
+
114
+ // MARK: - extractIndex
115
+
116
+ @Test func extractIndexReturnsNilWithoutAStampedIndex() {
117
+ let url = BrowserPathHelper.build(parentPath: "/library", trackId: "song.mp3")
118
+ #expect(BrowserPathHelper.extractIndex(url) == nil)
119
+ }
120
+
121
+ @Test func extractIndexReturnsNilForNonContextualOrMalformedIndex() {
122
+ #expect(BrowserPathHelper.extractIndex("/library") == nil)
123
+ #expect(BrowserPathHelper.extractIndex("/library?__trackId=song.mp3&__index=x") == nil)
124
+ #expect(BrowserPathHelper.extractIndex("/library?__trackId=song.mp3&__index=-1") == nil)
125
+ }
126
+
77
127
  // MARK: - stripTrackId
78
128
 
79
129
  @Test func stripTrackIdRemovesTrackParam() {
@@ -116,23 +166,3 @@ import Testing
116
166
  @Test func isContextualReturnsTrueWithAmpersandSeparator() {
117
167
  #expect(BrowserPathHelper.isContextual("/lib?q=1&__trackId=x") == true)
118
168
  }
119
-
120
- // MARK: - containsSegment
121
-
122
- @Test func containsSegmentMatchesFullSegments() {
123
- let path = "/listen/amsterdam-funk-channel/Gw0LGB8j"
124
- #expect(BrowserPathHelper.containsSegment(path, "Gw0LGB8j"))
125
- #expect(BrowserPathHelper.containsSegment(path, "amsterdam-funk-channel"))
126
- #expect(BrowserPathHelper.containsSegment(path, "listen"))
127
- #expect(BrowserPathHelper.containsSegment("/listen/x/Gw0LGB8j?hl=en", "Gw0LGB8j"))
128
- #expect(BrowserPathHelper.containsSegment("/listen/x/Gw0LGB8j#frag", "Gw0LGB8j"))
129
- }
130
-
131
- @Test func containsSegmentRejectsPartialAndMissing() {
132
- let path = "/listen/amsterdam-funk-channel/Gw0LGB8j"
133
- #expect(!BrowserPathHelper.containsSegment(path, "Gw0LGB8"))
134
- #expect(!BrowserPathHelper.containsSegment(path, "funk"))
135
- #expect(!BrowserPathHelper.containsSegment(path, "msterdam-funk-channe"))
136
- #expect(!BrowserPathHelper.containsSegment(path, "NopeNope"))
137
- #expect(!BrowserPathHelper.containsSegment(path, ""))
138
- }