react-native-queue-player 1.2.0 → 2.0.0

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 (355) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +839 -271
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  58. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  59. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  60. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  61. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  109. package/app.plugin.js +17 -1
  110. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  111. package/ios/AVQueueBuilder.swift +120 -85
  112. package/ios/ArtworkLoader.swift +0 -7
  113. package/ios/ArtworkResolver.swift +22 -4
  114. package/ios/AssetReplayGainReader.swift +118 -0
  115. package/ios/AudioSession.swift +67 -16
  116. package/ios/AudioTapProvider.swift +284 -149
  117. package/ios/CarPlayBridge.swift +0 -12
  118. package/ios/CarPlayCoordinator.swift +18 -2
  119. package/ios/CarPlaySceneDelegate.swift +2 -2
  120. package/ios/Cast/AirPlayRouteState.swift +9 -8
  121. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  122. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  123. package/ios/Cast/Core/CastSession.swift +2 -2
  124. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  125. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  126. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  127. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  128. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  129. package/ios/CastManager.swift +1 -1
  130. package/ios/CrossfadeEngine.swift +660 -226
  131. package/ios/EQTap.swift +20 -17
  132. package/ios/Equalizer.swift +1 -1
  133. package/ios/FLACStreamInfo.swift +65 -0
  134. package/ios/GaplessEngine.swift +57 -30
  135. package/ios/InputGuards.swift +35 -5
  136. package/ios/LookaheadCache.swift +202 -58
  137. package/ios/LookaheadCachePrefetcher.swift +48 -36
  138. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  139. package/ios/NetworkRecoveryPolicy.swift +32 -0
  140. package/ios/NowPlayingFormatExtractor.swift +25 -18
  141. package/ios/NowPlayingInfo.swift +84 -48
  142. package/ios/OutputRouteMonitor.swift +16 -3
  143. package/ios/PendingBrowseRequests.swift +1 -1
  144. package/ios/PlaceholderArtwork.swift +9 -5
  145. package/ios/PlaybackEngine.swift +165 -76
  146. package/ios/PlaybackErrorMapping.swift +3 -3
  147. package/ios/PlaybackModeStateMachine.swift +2 -2
  148. package/ios/PlaybackNetworkMonitor.swift +73 -0
  149. package/ios/PlayerStateDerivation.swift +6 -6
  150. package/ios/QueueMutationArithmetic.swift +25 -0
  151. package/ios/QueueSkipArithmetic.swift +5 -3
  152. package/ios/QueueWindowArithmetic.swift +152 -0
  153. package/ios/ReadThroughServer.swift +283 -0
  154. package/ios/RemoteCommands.swift +44 -4
  155. package/ios/ReplayGainData.swift +22 -3
  156. package/ios/ReplayGainExtractor.swift +70 -27
  157. package/ios/Siri/VoiceDonation.swift +14 -9
  158. package/ios/StreamingBitrateProbe.swift +22 -68
  159. package/ios/SuppliedReplayGain.swift +83 -0
  160. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  161. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  162. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  163. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  164. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  165. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  166. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  167. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  168. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  169. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  170. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  171. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  172. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  173. package/ios/Tests/EngineSwapTests.swift +91 -0
  174. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  175. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  176. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  177. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  178. package/ios/Tests/InputGuardsTests.swift +41 -0
  179. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  180. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  181. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  182. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  183. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  184. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  185. package/ios/Tests/MutationDeferralTests.swift +220 -0
  186. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  187. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  188. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  189. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  190. package/ios/Tests/PlayerFixtures.swift +48 -0
  191. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  192. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  193. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  194. package/ios/Tests/QueueStateTests.swift +3 -1
  195. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  196. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  197. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  198. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  199. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  200. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  201. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  202. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  203. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  204. package/ios/Tests/SkipIndexTests.swift +32 -2
  205. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  206. package/ios/Tests/StallRecoveryTests.swift +97 -0
  207. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  208. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  209. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  210. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  211. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  212. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  213. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  214. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  215. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  216. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  217. package/ios/TrackPlayer+Automotive.swift +101 -0
  218. package/ios/TrackPlayer+Cache.swift +232 -0
  219. package/ios/TrackPlayer+Config.swift +291 -0
  220. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  221. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  222. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  223. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  224. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  225. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  226. package/ios/TrackPlayer+Queue.swift +940 -0
  227. package/ios/TrackPlayer+Recovery.swift +196 -0
  228. package/ios/TrackPlayer+Skip.swift +455 -0
  229. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  230. package/ios/TrackPlayer+State.swift +108 -0
  231. package/ios/TrackPlayer+Threading.swift +135 -0
  232. package/ios/TrackPlayer+Transport.swift +267 -0
  233. package/ios/TrackPlayer+Window.swift +195 -0
  234. package/ios/TrackPlayer.swift +305 -4619
  235. package/ios/Visualizer.swift +6 -5
  236. package/ios/tests-harness/Podfile +1 -1
  237. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  238. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  239. package/lib/module/hooks/useActiveTrack.js +29 -22
  240. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  241. package/lib/module/hooks/useCast.js +8 -26
  242. package/lib/module/hooks/useCast.js.map +1 -1
  243. package/lib/module/hooks/useEqualizer.js +19 -12
  244. package/lib/module/hooks/useEqualizer.js.map +1 -1
  245. package/lib/module/hooks/useLookaheadCache.js +3 -7
  246. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  247. package/lib/module/hooks/useQueue.js +66 -19
  248. package/lib/module/hooks/useQueue.js.map +1 -1
  249. package/lib/module/index.js +6 -3
  250. package/lib/module/index.js.map +1 -1
  251. package/lib/module/queueDelta.js +41 -0
  252. package/lib/module/queueDelta.js.map +1 -0
  253. package/lib/module/types.js +28 -5
  254. package/lib/module/types.js.map +1 -1
  255. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  256. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  257. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  258. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  259. package/lib/typescript/hooks/useCast.d.ts +6 -1
  260. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  261. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  262. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  263. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  265. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  266. package/lib/typescript/index.d.ts +2 -1
  267. package/lib/typescript/index.d.ts.map +1 -1
  268. package/lib/typescript/queueDelta.d.ts +10 -0
  269. package/lib/typescript/queueDelta.d.ts.map +1 -0
  270. package/lib/typescript/types.d.ts +168 -12
  271. package/lib/typescript/types.d.ts.map +1 -1
  272. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  273. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  274. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  275. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  276. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  277. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  278. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  279. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  280. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  281. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  313. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  314. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  315. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  316. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  317. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  318. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  319. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  320. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  321. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  322. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  323. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  324. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  325. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  326. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  327. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  328. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  329. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  330. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  331. package/package.json +8 -8
  332. package/src/TrackPlayer.nitro.ts +53 -27
  333. package/src/hooks/useActiveTrack.ts +29 -22
  334. package/src/hooks/useCast.ts +14 -15
  335. package/src/hooks/useEqualizer.ts +19 -12
  336. package/src/hooks/useLookaheadCache.ts +3 -7
  337. package/src/hooks/useQueue.ts +66 -17
  338. package/src/index.ts +13 -3
  339. package/src/queueDelta.ts +41 -0
  340. package/src/types.ts +169 -12
  341. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  342. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  343. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  344. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  345. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  346. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  347. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  348. package/ios/MetadataReader.swift +0 -555
  349. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  350. package/ios/Tests/MetadataReaderTests.swift +0 -603
  351. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  352. package/ios/Tests/VoiceDonationTests.swift +0 -26
  353. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  354. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  355. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,940 @@
1
+ import AVFoundation
2
+ import NitroModules
3
+
4
+ extension TrackPlayer {
5
+ // MARK: - Queue mutation
6
+ //
7
+ // All mutations operate on two parallel structures:
8
+ // 1. `self.tracks` — the authoritative ordered TrackItem list.
9
+ // 2. The engine's installed run — the `QueueWindowArithmetic.slice`
10
+ // around `currentTrackIndex`, reconciled by `fullRebuildPlayerQueue`
11
+ // and the surgical insert/remove calls without tearing down the
12
+ // currently-playing item when possible.
13
+ //
14
+ // Thread safety: every mutation body is enqueued onto `playerQueue` by
15
+ // `enqueueThenMirror`.
16
+ // AVFoundation carries no main-thread requirement at the 16.0 floor, but it
17
+ // is not thread-safe either, so one queue owns every player mutation. The
18
+ // hop also serialises concurrent JS calls — two back-to-back `addToQueue` +
19
+ // `removeFromQueue` invocations cannot interleave, because the queue is
20
+ // serial.
21
+
22
+ /// Belt-and-braces: verify the parallel-array invariant before
23
+ /// any function that consumes both arrays. `QueueState` enforces
24
+ /// this at every mutation site, but a direct read of either array
25
+ /// outside `queueState`'s API can drift if a future contributor
26
+ /// bypasses the type. Catches the desync earlier than the
27
+ /// construction-time `precondition` in `AVQueueBuilder`.
28
+ /// Debug-only — the precondition vanishes in Release builds where
29
+ /// `AVQueueBuilder` already fails soft (returns `[]` + NSLog) on
30
+ /// the same desync.
31
+ private func assertQueueIdsInvariant(_ caller: StaticString) {
32
+ #if DEBUG
33
+ precondition(
34
+ self.tracks.count == self.queueItemIds.count,
35
+ "QueueState parallel-array invariant violated at \(caller): tracks=\(self.tracks.count), queueItemIds=\(self.queueItemIds.count)"
36
+ )
37
+ #endif
38
+ }
39
+
40
+ /// Raise `isMutatingPlayerQueue` for the duration of `body`,
41
+ /// restoring the prior value on exit (so nested calls stay true
42
+ /// throughout the outer mutation). Used to suppress spurious
43
+ /// `onQueueEnd` / `onTrackChange` fires from the `\.currentItem`
44
+ /// KVO during the `removeAllItems` → `insert` intermediate nil
45
+ /// window. Must be called on `playerQueue`.
46
+ internal func performingMutation(_ body: () -> Void) {
47
+ let wasAlready = self.isMutatingPlayerQueue
48
+ self.isMutatingPlayerQueue = true
49
+ defer { self.isMutatingPlayerQueue = wasAlready }
50
+ body()
51
+ }
52
+
53
+ func setQueue(tracks: [TrackItem], startAtIndex: Double?) throws -> Promise<Void> {
54
+ // The local queue model is replaced **at call time**, before any cast
55
+ // round-trip, so the mutation reaches the player in the order JS issued it
56
+ // — a `skipToNext()` called straight after this one cannot overtake it.
57
+ // The receiver is mirrored afterwards from the Task below.
58
+ //
59
+ // Local state is maintained whether or not the receiver takes the queue:
60
+ // JS reads (`getQueue` / `getCurrentTrackIndex`) reflect the user-visible
61
+ // queue while the receiver is authoritative for playback, and the local
62
+ // engine is left holding the right items for when cast disconnects. The
63
+ // engine is already paused during cast and the rebuild below keeps it
64
+ // paused, so this is silent either way.
65
+ return enqueueThenMirror({
66
+ self.performingMutation {
67
+ // `self.engine` is the canonical "lib initialised" sentinel.
68
+ // `self.player` is the gapless engine's AVQueuePlayer and is
69
+ // nil under CrossfadeEngine — gating on it here would silently
70
+ // no-op every queue mutation made while in crossfade mode.
71
+ guard self.engine != nil else { return }
72
+ let newIds = self.queueState.replaceAll(tracks)
73
+ self.currentTrackIndex = Self.resolvedStartIndex(
74
+ startAtIndex: startAtIndex, trackCount: tracks.count)
75
+ self.pendingTrackChangeReason = .queueReplaced
76
+ // A fresh queue is not over, whatever the one it replaces was.
77
+ self.reachedQueueEnd = false
78
+ // Fresh queue → fresh dedup state. Without this an error on
79
+ // the prior queue's last item could suppress an identical
80
+ // coded error on the new queue's first item.
81
+ self.lastErrorQueueItemId = nil
82
+ self.lastErrorCode = nil
83
+ // Reset auto-retry counters: new queueItemIds → new budget
84
+ // entries. The retry budget is per-queue-position; old
85
+ // entries from the prior queue are gone with the prior
86
+ // queueItemIds, and a missing entry reads as zero retries.
87
+ let attempts = self.effectiveAutoRetries()
88
+ self.retryAttemptsRemaining = Dictionary(
89
+ uniqueKeysWithValues: newIds.map { ($0, attempts) }
90
+ )
91
+ // A fresh queue diverges at position 0, so the rebuild installs the
92
+ // whole slice and the resolved start track gets initial-buffer
93
+ // leniency from the rearm on that path.
94
+ self.syncPlayerQueue(preserveCurrent: false)
95
+ }
96
+ self.rescheduleLookahead()
97
+ self.recomputeCapabilities()
98
+ self.emitQueueChange(
99
+ .setQueue,
100
+ inserted: self.tracks,
101
+ insertedAt: self.tracks.isEmpty ? -1 : 0
102
+ )
103
+ }, mirror: { _, generation in
104
+ // Push the queue to the receiver. Replacing the whole queue starts the
105
+ // new queue there (distinct from `handoffCurrentPlaybackToCast`, which
106
+ // preserves the in-flight play/pause state). A receiver-side failure
107
+ // needs no fallback — the local path above already ran.
108
+ let resolvedStart = Self.resolvedStartIndex(
109
+ startAtIndex: startAtIndex, trackCount: tracks.count)
110
+ let castItems = tracks.compactMap { CastMediaItem.from(track: $0) }
111
+ // Skip the cast route if EVERY track had an unparseable url
112
+ // (`from(track:)` returned nil). Empty queues mean nothing to play.
113
+ guard !tracks.isEmpty, castItems.count == tracks.count else { return }
114
+ // nil covers both "no receiver is active" and "the receiver-side load
115
+ // failed"; neither needs anything beyond the local queue already set.
116
+ let routed = (try? await CastTransportRouter.routeSetQueue(
117
+ tracks: castItems,
118
+ startIndex: max(0, resolvedStart),
119
+ startPositionMs: 0,
120
+ playWhenReady: true
121
+ )) != nil
122
+ guard routed else { return }
123
+ // Seed the start track's lockscreen metadata immediately so the surface
124
+ // doesn't wait for the first receiver status. The current track is
125
+ // resolved from the receiver's absolute queue index, not the itemIds, so
126
+ // no itemId map is needed here.
127
+ //
128
+ // The seed reads the live queue, so the check belongs inside this hop:
129
+ // a `setQueue` that landed while the load was in flight has already
130
+ // replaced `tracks`, and `resolvedStart` names a position in the queue
131
+ // that lost. That newer mutation seeds its own start track.
132
+ await self.onPlayerQueue {
133
+ guard self.queueMutationGeneration == generation else { return }
134
+ self.castEventBridge.seedStartMetadata(startIndex: resolvedStart)
135
+ }
136
+ })
137
+ }
138
+
139
+ /// Clamp the consumer-supplied start index into the queue bounds. An empty
140
+ /// queue ignores the parameter; nil, non-finite, negative and out-of-range
141
+ /// all coerce to 0.
142
+ ///
143
+ /// The clamp happens in `Double` space because `Int(.nan)` and
144
+ /// `Int(.infinity)` are undefined behaviour — see `InputGuards`.
145
+ private static func resolvedStartIndex(
146
+ startAtIndex: Double?, trackCount: Int
147
+ ) -> Int {
148
+ guard trackCount > 0 else { return -1 }
149
+ guard let requested = startAtIndex, requested.isFinite else { return 0 }
150
+ return Int(max(0, min(requested, Double(trackCount - 1))))
151
+ }
152
+
153
+ /// Adding to an empty queue (cur == -1) leaves cur at -1 and leaves
154
+ /// the player empty — consumer must call `skipToIndex(0)` to start
155
+ /// playback. This avoids implicit "start playing" surprises on
156
+ /// `addToQueue`. Non-empty queue with
157
+ /// insertion strictly after cur preserves the currently-playing
158
+ /// item surgically. Insertion before/at cur shifts cur forward;
159
+ /// since AVQueuePlayer holds only cur-onward items, the player
160
+ /// queue is unchanged by before-cur insertions (no playback gap).
161
+ func addToQueue(tracks newTracks: [TrackItem], insertBefore: Double?) throws -> Promise<Void> {
162
+ // The local mutation is enqueued **at call time** so it reaches the player
163
+ // in the order JS issued it, and the receiver is mirrored afterwards.
164
+ //
165
+ // An insert into the immediate-next slot of a playing queue is the one
166
+ // `AVQueuePlayer` prerolls on the shared render pipeline mid-playback,
167
+ // which briefly disturbs the current item. The insert is not held until
168
+ // the asset has loaded: a hold lets a transport call issued straight
169
+ // afterwards reach the player first and act on the pre-insert queue.
170
+ // Ordering wins — and in the sequence where it matters, "play next" then
171
+ // skip, the current item is being left anyway.
172
+ return enqueueThenMirror({ () -> Int in
173
+ let at = QueueMutationArithmetic.clampInsertBefore(
174
+ insertBefore: InputGuards.insertPosition(insertBefore, count: self.tracks.count),
175
+ trackCount: self.tracks.count
176
+ )
177
+ self.performingMutation {
178
+ self.addToQueueBody(newTracks: newTracks, insertBefore: insertBefore)
179
+ }
180
+ self.rescheduleLookahead()
181
+ self.recomputeCapabilities()
182
+ return at
183
+ }, mirror: { insertAt, _ in
184
+ // Mirror only when every new track is castable. A dropped item would
185
+ // shift the receiver queue out of alignment with the local index space
186
+ // that later mutations (remove/move) resolve against, so a partial set
187
+ // is not routed at all — the same all-or-nothing rule `setQueue` applies.
188
+ let castItems = newTracks.compactMap { CastMediaItem.from(track: $0) }
189
+ if !castItems.isEmpty, castItems.count == newTracks.count {
190
+ _ = await CastTransportRouter.routeAddToQueue(
191
+ items: castItems, beforeIndex: insertAt)
192
+ }
193
+ })
194
+ }
195
+
196
+ private func addToQueueBody(
197
+ newTracks: [TrackItem],
198
+ insertBefore: Double?
199
+ ) {
200
+ guard !newTracks.isEmpty, let engine = self.engine else { return }
201
+
202
+ // Non-finite `insertBefore` is dropped (treated as append) so
203
+ // `Int($0)` never traps on NaN / ±Infinity. Mirrors Android's
204
+ // `takeIf { it.isFinite() }?.toInt()` sanitisation at the
205
+ // mutation boundary.
206
+ let at = QueueMutationArithmetic.clampInsertBefore(
207
+ insertBefore: InputGuards.insertPosition(insertBefore, count: self.tracks.count),
208
+ trackCount: self.tracks.count
209
+ )
210
+
211
+ // An insert lands in front of `at`, so that position and the one before it
212
+ // are what the engine would have to be told about.
213
+ let deferred = deferEngineWork(touching: [at - 1, at])
214
+ let newIds = self.queueState.insert(newTracks, at: at)
215
+ // Initialise retry-budget entries for the new items so they have
216
+ // an auto-retry budget when (if) they hit a transient error.
217
+ let attempts = self.effectiveAutoRetries()
218
+ for id in newIds {
219
+ self.retryAttemptsRemaining[id] = attempts
220
+ }
221
+
222
+ let oldCur = self.currentTrackIndex
223
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
224
+ currentIndex: oldCur, insertAt: at, count: newTracks.count
225
+ )
226
+ // Queue data model is final here (tracks + index committed); the
227
+ // engine insert below only touches the audio pipeline. Emit before
228
+ // the early returns so every non-empty add reports one snapshot.
229
+ self.emitQueueChange(.add, inserted: newTracks, insertedAt: at)
230
+
231
+ if oldCur < 0 {
232
+ // Queue was empty — player stays empty until skipToIndex.
233
+ return
234
+ }
235
+
236
+ if at <= oldCur {
237
+ // Insertion at/before cur: player queue (cur-onward) is
238
+ // unchanged; only cur shifted forward (already applied via
239
+ // `currentIndexAfterAdd` above).
240
+ return
241
+ }
242
+
243
+ // A queue that has ended stays ended until a skip or a seek: the engine
244
+ // is left alone, whether it drained (gapless) or still holds the finished
245
+ // item (crossfade), and the model carries the insert until then. Behind
246
+ // a leg parked at its end, an inserted item would be what the next
247
+ // `play()` fades into.
248
+ if self.reachedQueueEnd { return }
249
+
250
+ // Insertion strictly after cur: surgical insert preserves the
251
+ // currently-playing item and any preloaded look-ahead state.
252
+ // Anchor on the track immediately before the insertion point,
253
+ // matched by lib-generated identity so it resolves in either
254
+ // engine's live queue (cur-onward for GaplessEngine, installed slice
255
+ // for CrossfadeEngine).
256
+ let existing = engine.allMediaItems
257
+ let anchorId: String? = (at - 1 >= 0 && at - 1 < self.queueItemIds.count)
258
+ ? self.queueItemIds[at - 1] : nil
259
+ let anchorItem = existing.first { $0.queueItemId != nil && $0.queueItemId == anchorId }
260
+
261
+ guard let anchorItem else {
262
+ // The track before the insertion point is not materialised, so there
263
+ // is no position to insert after: falling back to the last enqueued
264
+ // item would play the new tracks ahead of every position between it
265
+ // and them, and an `insert(after: nil)` into a drained engine seats
266
+ // playback on the new track while `currentTrackIndex` stays on the
267
+ // one that finished. Leave the engine alone — `self.tracks` already
268
+ // carries the insert, and the queue is rebuilt from it on the way
269
+ // past.
270
+ return
271
+ }
272
+
273
+ guard !deferred else { return }
274
+ // Only what the window holds is built here. An add that runs past the
275
+ // window — a whole album dropped in behind the playing track — goes
276
+ // through the rebuild instead, which trims the installed tail and
277
+ // appends the slice; `topUpWindow` materialises the rest as the playhead
278
+ // advances. Inserting every added item surgically would build each one
279
+ // now, and leave the engine holding an unbroken run only if all of them
280
+ // were inserted.
281
+ let window = QueueWindowArithmetic.slice(
282
+ currentIndex: self.currentTrackIndex,
283
+ trackCount: self.tracks.count,
284
+ trail: self.engineTrail,
285
+ wraps: false
286
+ )
287
+ guard window.contains(at + newTracks.count - 1) else {
288
+ self.fullRebuildPlayerQueue()
289
+ return
290
+ }
291
+ let newItems = AVQueueBuilder.buildPlayerItems(
292
+ tracks: newTracks, queueItemIds: newIds,
293
+ config: self.config, server: self.routingServer)
294
+ let ok = engine.insertItems(newItems, after: anchorItem)
295
+ if !ok {
296
+ // Surgical insert failed — recover by rebuilding the tail
297
+ // so `self.tracks` and the engine queue cannot diverge.
298
+ self.syncPlayerQueue(preserveCurrent: true)
299
+ }
300
+ }
301
+
302
+ func removeFromQueue(indices: [Double]) throws -> Promise<Void> {
303
+ // Capture the sanitised indices against the pre-mutation queue and mutate
304
+ // local **at call time**, so the mutation reaches the player in the order
305
+ // JS issued it; then mirror the removal to the receiver (resolved to
306
+ // stable itemIds inside the cast session before removing). Inside
307
+ // `Promise.async` the mutation would be an unstructured Task's first
308
+ // statement, which a later call's Task can beat to the queue.
309
+ return enqueueThenMirror({
310
+ // Exclude the current index — the currently-playing track is
311
+ // pinned on the receiver too, matching the local body below.
312
+ let cur = self.currentTrackIndex
313
+ let sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
314
+ rawIndices: indices.compactMap {
315
+ InputGuards.validQueueIndex($0, count: self.tracks.count)
316
+ },
317
+ trackCount: self.tracks.count
318
+ ).filter { $0 != cur }
319
+ self.performingMutation {
320
+ self.removeFromQueueBody(indices: indices)
321
+ }
322
+ self.rescheduleLookahead()
323
+ self.recomputeCapabilities()
324
+ return sanitised
325
+ }, mirror: { castIndices, _ in
326
+ if !castIndices.isEmpty {
327
+ _ = await CastTransportRouter.routeRemoveFromQueue(indices: castIndices)
328
+ }
329
+ })
330
+ }
331
+
332
+ private func removeFromQueueBody(indices: [Double]) {
333
+ guard !indices.isEmpty, let engine = self.engine else { return }
334
+ // `removeIds` below force-subscripts `queueItemIds`; this asserts the
335
+ // parallel-array invariant the sanitiser's in-range output relies on.
336
+ assertQueueIdsInvariant("removeFromQueueBody")
337
+
338
+ // Bounds-check in `Double` space: `Int(_:)` from a floating-point source
339
+ // traps on non-finite and out-of-Int-range values, and these arrive
340
+ // straight from JS. See `InputGuards`.
341
+ var sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
342
+ rawIndices: indices.compactMap {
343
+ InputGuards.validQueueIndex($0, count: self.tracks.count)
344
+ },
345
+ trackCount: self.tracks.count
346
+ )
347
+ // The currently-playing track is pinned: it cannot be removed via
348
+ // removeFromQueue (a consumer that wants it gone skips off it first,
349
+ // then removes it). Drop the current index from the set — a request
350
+ // to remove only the current track is a no-op; a mixed request drops
351
+ // the other tracks and keeps the current one.
352
+ sanitised.removeAll { $0 == self.currentTrackIndex }
353
+ guard !sanitised.isEmpty else { return }
354
+
355
+ let trackCountBefore = self.tracks.count
356
+ let oldCur = self.currentTrackIndex
357
+
358
+ // Match the AVPlayerItems to drop by their lib-generated identity
359
+ // rather than by index offset, so the removal is engine-agnostic:
360
+ // GaplessEngine exposes only the cur-onward slice (consumed items
361
+ // are gone), while CrossfadeEngine exposes its installed slice. Captured
362
+ // before the `queueState.remove` loop below mutates the id array.
363
+ // Decided here, while the positions still name the tracks being removed.
364
+ let deferred = deferEngineWork(touching: sanitised)
365
+ let removeIds = Set(sanitised.map { self.queueItemIds[$0] })
366
+ let itemsToRemove = deferred ? [] : engine.allMediaItems.filter { item in
367
+ guard let id = item.queueItemId else { return false }
368
+ return removeIds.contains(id)
369
+ }
370
+
371
+ // Iterate in descending order so earlier indices stay valid as
372
+ // each removal shifts the remainder. Drop the matching
373
+ // retry-budget entry so the map doesn't grow stale.
374
+ for i in sanitised.reversed() {
375
+ let removedId = self.queueState.remove(at: i)
376
+ self.retryAttemptsRemaining.removeValue(forKey: removedId)
377
+ }
378
+
379
+ // The current track is never in the removed set, so it keeps playing
380
+ // and its identity is unchanged; only its index shifts down by the
381
+ // number of removed earlier tracks.
382
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
383
+ currentIndex: oldCur,
384
+ sanitisedIndices: sanitised,
385
+ trackCountBefore: trackCountBefore
386
+ )
387
+ // Reached only past the `sanitised.isEmpty` guard, so ≥1 track was
388
+ // removed. tracks + index are final; the engine.remove loop below
389
+ // only detaches the AVPlayerItems.
390
+ self.emitQueueChange(.remove, removed: sanitised)
391
+
392
+ // The currentItem is untouched (never in `itemsToRemove`); removing
393
+ // only later/earlier items leaves it playing in place.
394
+ for item in itemsToRemove {
395
+ engine.remove(item)
396
+ }
397
+ guard !deferred else { return }
398
+ // Removing the queued-ahead tracks takes them straight out of the engine,
399
+ // so the run can be left at the playing item with the queue far from over.
400
+ self.topUpWindow()
401
+ }
402
+
403
+ func moveInQueue(fromIndex: Double, toIndex: Double) throws -> Promise<Void> {
404
+ // Capture the validated from/to against the pre-mutation queue and mutate
405
+ // local **at call time**, so the mutation reaches the player in JS call
406
+ // order; then mirror the reorder to the receiver.
407
+ return enqueueThenMirror({ () -> (from: Int, to: Int)? in
408
+ let count = self.tracks.count
409
+ // Mirrored to the receiver only when the move is one the local body
410
+ // will also perform; the body applies the same rules itself.
411
+ var resolved: (from: Int, to: Int)?
412
+ if let from = InputGuards.validQueueIndex(fromIndex, count: count),
413
+ let to = InputGuards.validQueueIndex(toIndex, count: count),
414
+ from != to, from != self.currentTrackIndex {
415
+ resolved = (from, to)
416
+ }
417
+ self.performingMutation {
418
+ self.moveInQueueBody(fromIndex: fromIndex, toIndex: toIndex)
419
+ }
420
+ self.rescheduleLookahead()
421
+ self.recomputeCapabilities()
422
+ return resolved
423
+ }, mirror: { move, _ in
424
+ if let move = move {
425
+ _ = await CastTransportRouter.routeMoveInQueue(fromIndex: move.from, toIndex: move.to)
426
+ }
427
+ })
428
+ }
429
+
430
+ private func moveInQueueBody(fromIndex: Double, toIndex: Double) {
431
+ guard self.engine != nil else { return }
432
+ // Bounds-check in `Double` space: `Int(_:)` from a floating-point source
433
+ // traps on non-finite and out-of-Int-range values, and these arrive
434
+ // straight from JS. See `InputGuards`.
435
+ let count = self.tracks.count
436
+ guard let from = InputGuards.validQueueIndex(fromIndex, count: count),
437
+ let to = InputGuards.validQueueIndex(toIndex, count: count),
438
+ // The currently-playing track cannot be reordered — only the tracks
439
+ // around it move. Reordering the current item would force a full
440
+ // rebuild that restarts the active track.
441
+ from != to,
442
+ from != self.currentTrackIndex else { return }
443
+
444
+ // Both ends and the neighbours the item lands between, decided before the
445
+ // model changes while those positions still name the same tracks. A move
446
+ // is a remove then an insert, so a forward move lands between the
447
+ // pre-mutation `to` and `to + 1` while a backward one lands between
448
+ // `to - 1` and `to`; both neighbourhoods are covered rather than reasoned
449
+ // about per direction.
450
+ let deferred = deferEngineWork(touching: [from, to - 1, to, to + 1])
451
+ let oldCur = self.currentTrackIndex
452
+ self.queueState.move(from: from, to: to)
453
+
454
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
455
+ currentIndex: oldCur, fromIndex: from, toIndex: to
456
+ )
457
+ let newCur = self.currentTrackIndex
458
+
459
+ // Decide engine-queue adjustment:
460
+ // * cur unchanged (oldCur == newCur) AND cur track wasn't
461
+ // moved: engine queue is stale only in the tail; rebuild
462
+ // tail while preserving currentItem.
463
+ // * cur moved (from == oldCur) OR cur's physical position
464
+ // shifted: reconcile against the whole slice. The rebuild keeps the
465
+ // leading run it can, so the playing track survives a move that
466
+ // leaves it at the head.
467
+ let preserveCurrent = (from != oldCur) && (newCur == oldCur)
468
+ // Emitted before the early return, like add and remove: the queue model is
469
+ // reordered whether or not the engine has been told yet, and a consumer
470
+ // rendering from this event would otherwise show the old order.
471
+ self.emitQueueChange(.move, movedFrom: from, movedTo: to)
472
+ guard !deferred else { return }
473
+ // A queue that has ended stays ended until a skip, a seek or a fresh
474
+ // queue: the model carries the move and the rebuild those run picks it
475
+ // up. Reconciling now would put a track behind the parked leg.
476
+ if self.reachedQueueEnd { return }
477
+ // performingMutation so the crossfade engine's setItems →
478
+ // engineDidTransitionTrack doesn't clobber the move-computed
479
+ // currentTrackIndex with an index resolved from the engine mid-rebuild (mirrors the
480
+ // setQueue paths, which already mutate under this guard).
481
+ self.performingMutation {
482
+ self.syncPlayerQueue(preserveCurrent: preserveCurrent)
483
+ }
484
+
485
+ }
486
+
487
+ func clearQueue() throws -> Promise<Void> {
488
+ // Enqueued at call time like every other queue mutation, so a `setQueue`
489
+ // issued straight afterwards cannot be cleared by this one landing late.
490
+ return enqueueThenMirror({
491
+ let queueWasNonEmpty = !self.tracks.isEmpty
492
+ self.performingMutation {
493
+ self.engine?.removeAllItems()
494
+ self.queueState.clear()
495
+ self.currentTrackIndex = -1
496
+ self.currentTrackSource = nil
497
+ // No rearm here — the next `setQueue` goes through
498
+ // `fullRebuildPlayerQueue` which rearms before inserts,
499
+ // so the clean-slate initial-buffer phase is preserved.
500
+ }
501
+ // `performingMutation` suppresses the `\.currentItem` → nil
502
+ // KVO fire; emit a synthetic trackChange here so JS hooks
503
+ // (e.g. `useActiveTrack`) clear their (track, index) snapshot.
504
+ // Mirrors Android `clearQueueInternal` which fires
505
+ // `(null, -1, QUEUE_REPLACED)` via Media3's onMediaItemTransition
506
+ // → `handleMediaItemTransition`. Reset `lastEmittedQueueItemId`
507
+ // so a subsequent `setQueue` re-emits cleanly via the dedup
508
+ // gate at `handleCurrentItemDidChange`.
509
+ self.lastEmittedQueueItemId = nil
510
+ self.lastErrorQueueItemId = nil
511
+ self.lastErrorCode = nil
512
+ self.retryAttemptsRemaining.removeAll()
513
+ // Fire queue-change BEFORE the synthetic track-change so a
514
+ // `useQueue()` consumer sees the empty queue + index -1 together,
515
+ // not a one-frame (old queue, index -1) transient. Only when the
516
+ // queue was actually non-empty — clearing an empty queue is a no-op.
517
+ if queueWasNonEmpty { self.emitQueueChange(.clear) }
518
+ self.trackChangeListeners.forEach { $0(nil, Double(-1), .queueReplaced, nil) }
519
+ self.nowPlayingInfo.refreshAll(self.nowPlayingSnapshot())
520
+ self.artworkResolver.cancelInFlight()
521
+ // Drop any cached now-playing format + emit null so JS-side
522
+ // consumers of `onNowPlayingFormatChange` see the queue-cleared
523
+ // state without waiting for the engine's `currentMediaItem` to
524
+ // settle (it can briefly retain the last item post-removeAll).
525
+ self.refreshNowPlayingFormatForActiveItem()
526
+ self.rescheduleLookahead()
527
+ self.recomputeCapabilities()
528
+ // The queue is empty now → settle buffer state + fully-buffered to their
529
+ // empty defaults and emit the transition. The `\.currentItem → nil` KVO
530
+ // is suppressed under `performingMutation`, so recompute explicitly.
531
+ self.hasStartedPlaying = false
532
+ self.wantsToPlay = false
533
+ self.recomputeBufferState()
534
+ self.recomputeFullyBuffered()
535
+ }, mirror: { _, _ in
536
+ // Mirror the clear to the receiver (removes every receiver queue item).
537
+ _ = await CastTransportRouter.routeClearQueue()
538
+ })
539
+ }
540
+
541
+ func getQueue() throws -> Promise<[TrackItem]> {
542
+ return enqueueValue { self.tracks }
543
+ }
544
+
545
+ /// Sync the engine's queue with `self.tracks[currentTrackIndex...]`.
546
+ ///
547
+ /// When `preserveCurrent` is true AND the engine has a currentItem,
548
+ /// that item is kept in place and only the tail (items after
549
+ /// current) is rebuilt — avoids a playback restart of the current
550
+ /// track. Used for mutations that leave the currently-playing
551
+ /// track untouched (e.g. remove-after-cur, move-entirely-after-cur).
552
+ ///
553
+ /// When false (or when there is no currentItem), reconciles against the
554
+ /// whole slice. That only restarts the current track when the slice actually
555
+ /// diverges at its head — a rebuild keeps whatever leading run the engine
556
+ /// already holds.
557
+ internal func syncPlayerQueue(preserveCurrent: Bool) {
558
+ assertQueueIdsInvariant("syncPlayerQueue")
559
+ guard let engine = self.engine else { return }
560
+ guard self.currentTrackIndex >= 0,
561
+ self.currentTrackIndex < self.tracks.count else {
562
+ engine.removeAllItems()
563
+ return
564
+ }
565
+
566
+ // The branch resolves before the tail build: `fullRebuildPlayerQueue`
567
+ // constructs `tracks[cur...]` itself, and every constructed item starts
568
+ // an `automaticallyLoadedAssetKeys` load plus, on a cache hit, a `touch`
569
+ // that queues an index-plist write. Keep this guard above the build.
570
+ // Anchor on the leg being heard, not on `currentMediaItem` — that switches
571
+ // to the incoming leg the moment a fade starts, so the trim below would
572
+ // remove the item actually playing. A fade in flight goes to the full
573
+ // rebuild, which resolves the anchor by identity and guards the trim.
574
+ guard preserveCurrent, !engine.isFadePendingOrActive,
575
+ let currentItem = engine.leadingQueueItemId.flatMap({ id in
576
+ engine.allMediaItems.first { $0.queueItemId == id }
577
+ })
578
+ else {
579
+ return self.fullRebuildPlayerQueue()
580
+ }
581
+
582
+ // Build the tail post-currentItem. The origin comes from the engine's
583
+ // own item: `currentTrackIndex` is resynced a main-queue hop later, so
584
+ // reading it here can place the tail one position out and leave the
585
+ // engine's run non-contiguous.
586
+ let currentPosition = matchTrackIndex(forCurrentItem: currentItem) ?? self.currentTrackIndex
587
+ let tailStart = currentPosition + 1
588
+ // Bounded to the floor on both engines, so a move on a long queue builds
589
+ // at most a window of items. The trail is not rebuilt through this path;
590
+ // the next reconcile restores it.
591
+ let tailEnd = min(currentPosition + QueueWindowArithmetic.floor, self.tracks.count)
592
+ let tailTracks = Array(self.tracks[tailStart ..< tailEnd])
593
+ let tailIds = Array(self.queueItemIds[tailStart ..< tailEnd])
594
+ let tailItems = AVQueueBuilder.buildPlayerItems(
595
+ tracks: tailTracks, queueItemIds: tailIds,
596
+ config: self.config, server: self.routingServer)
597
+
598
+ let dropped = engine.allMediaItems.filter { $0 !== currentItem }
599
+ AVQueueBuilder.cancelLoading(dropping: dropped, keeping: [currentItem] + tailItems)
600
+ for item in dropped {
601
+ engine.remove(item)
602
+ }
603
+ if !engine.insertItems(tailItems, after: currentItem) {
604
+ // Couldn't insert into the live queue — fall back to a full
605
+ // rebuild so authoritative state + player converge.
606
+ self.fullRebuildPlayerQueue()
607
+ }
608
+ }
609
+
610
+ /// Whether the engine's items can be left alone until the fade settles.
611
+ ///
612
+ /// Any change to the installed items cancels an in-flight fade, so a
613
+ /// mutation that touches neither leg is held back rather than cutting a
614
+ /// crossfade short for a part of the queue nobody is hearing. One that does
615
+ /// touch a leg cannot wait: letting the arm chain ride on through would
616
+ /// fade into a track the queue no longer has.
617
+ internal func deferEngineWork(touching positions: [Int]) -> Bool {
618
+ guard let engine = self.engine, engine.isFadePendingOrActive else { return false }
619
+ let attached = Set(engine.attachedQueueItemIds)
620
+ let touched = positions.compactMap { self.queueItemIds[safe: $0] }
621
+ // No resolvable position means nothing is known about what this touches,
622
+ // which is not the same as knowing it touches nothing.
623
+ guard !touched.isEmpty else { return false }
624
+ guard touched.allSatisfy({ !attached.contains($0) }) else { return false }
625
+ self.engineReconcileDeferred = true
626
+ return true
627
+ }
628
+
629
+ /// Apply a mutation the engine was not told about while a fade was running.
630
+ ///
631
+ /// Called when the fade settles, either way it settles. The reconcile
632
+ /// re-derives everything from the model, so it does not matter how many
633
+ /// mutations were held back or in what order they arrived.
634
+ internal func flushDeferredEngineWork() {
635
+ guard self.engineReconcileDeferred else { return }
636
+ // A fade that is still pending has not settled; the work keeps waiting.
637
+ guard self.engine?.isFadePendingOrActive != true else { return }
638
+ // Every engine mutation cancels a fade, and a cancel settles one, so this
639
+ // is reachable from inside a rebuild that is part-way through its own
640
+ // trim-and-append. Rebuilding now would run against the outer pass's stale
641
+ // view of the installed run and could append a second copy of an item it
642
+ // still believes it holds. Keep the work owed and take it on the next hop,
643
+ // when the outer mutation has finished.
644
+ guard !self.isMutatingPlayerQueue else {
645
+ self.playerQueue.async { [weak self] in self?.flushDeferredEngineWork() }
646
+ return
647
+ }
648
+ self.engineReconcileDeferred = false
649
+ self.performingMutation { self.fullRebuildPlayerQueue() }
650
+ self.rescheduleLookahead()
651
+ self.recomputeCapabilities()
652
+ }
653
+
654
+ /// How many played positions the engine keeps behind the playhead.
655
+ ///
656
+ /// The gapless `AVQueuePlayer` consumes finished items, so it can hold none
657
+ /// and asking for a trail there would be asking for items it drops anyway.
658
+ /// The crossfade engine retains its whole installed run, so without a trail
659
+ /// the run only grows — which moves the memory peak later rather than
660
+ /// lowering it. Two positions buys `AVURLAsset` reuse for an immediate
661
+ /// backward skip and nothing beyond that, which is why it is not deeper.
662
+ internal var engineTrail: Int {
663
+ self.player != nil ? 0 : 2
664
+ }
665
+
666
+ /// Bring the engine's items in line with the positions it should be holding.
667
+ ///
668
+ /// The positions come from `QueueWindowArithmetic.slice`, which is the one
669
+ /// answer to "what should be installed" — a trail behind the playhead and
670
+ /// the floor ahead of it, stopping at the last position whatever the repeat
671
+ /// mode; the wrap is decided on the drain path by `applyEndVerdict`.
672
+ ///
673
+ /// Reconciling rather than reinstalling: positions that fell off the back of
674
+ /// the trail are removed, the run the engine already holds correctly is
675
+ /// kept, and only the rest is built. Installing the whole slice would
676
+ /// discard the item already buffered, which is audible as a cut when this
677
+ /// lands at a boundary.
678
+ ///
679
+ /// `forcingReinstall` is for the callers that rebuild in order to replace a
680
+ /// *specific* item rather than to follow a model change — the retry paths,
681
+ /// which exist to construct a fresh item over one that has failed
682
+ /// terminally. A failed item keeps its `queueItemId`, so identity cannot
683
+ /// tell a dead item from a live one and the caller has to say.
684
+ internal func fullRebuildPlayerQueue(forcingReinstall: Bool = false) {
685
+ assertQueueIdsInvariant("fullRebuildPlayerQueue")
686
+ guard let engine = self.engine else { return }
687
+ guard self.currentTrackIndex >= 0,
688
+ self.currentTrackIndex < self.tracks.count else {
689
+ engine.removeAllItems()
690
+ return
691
+ }
692
+ let cur = self.currentTrackIndex
693
+ // `wraps: false` deliberately. Carrying the head into the run at the tail
694
+ // would let a repeat-queue boundary crossfade instead of cutting, but it
695
+ // also moves the wrap off the drain path: the engine advances into the
696
+ // wrap target itself instead of reporting that it ran out, and
697
+ // `applyEndVerdict` never decides the wrap. The run stays forward-only;
698
+ // `slice` carries the arithmetic for a wrapping run.
699
+ let positions = QueueWindowArithmetic.slice(
700
+ currentIndex: cur,
701
+ trackCount: self.tracks.count,
702
+ trail: self.engineTrail,
703
+ wraps: false
704
+ )
705
+ guard !positions.isEmpty else {
706
+ engine.removeAllItems()
707
+ return
708
+ }
709
+ let slice = positions.map { self.tracks[$0] }
710
+ let sliceIds = positions.map { self.queueItemIds[$0] }
711
+ let currentId = self.queueItemIds[cur]
712
+
713
+ let installed = engine.allMediaItems
714
+ // Where the item being heard sits in the run. The trim must never reach
715
+ // it: removing it replaces the leading leg's current item, which is a cut
716
+ // in the middle of a track.
717
+ let leading = engine.leadingQueueItemId.flatMap { id in
718
+ installed.firstIndex { $0.queueItemId == id }
719
+ }
720
+
721
+ // Anchor on the first wanted position the engine actually holds, but never
722
+ // past the current one. The trail is an optimisation — a missing trail
723
+ // position is not worth reinstalling the run and restarting what is
724
+ // playing — while a missing current position is exactly what a reinstall
725
+ // is for.
726
+ let curOffset = positions.firstIndex(of: cur) ?? 0
727
+ var anchor = 0
728
+ while anchor < curOffset,
729
+ !installed.contains(where: { $0.queueItemId == sliceIds[anchor] }) {
730
+ anchor += 1
731
+ }
732
+ let wantedIds = Array(sliceIds[anchor...])
733
+ let wantedTracks = Array(slice[anchor...])
734
+
735
+ // Positions that have fallen off the back of the run come off the front of
736
+ // what is installed. A run that has rotated past the leading item cannot
737
+ // be expressed as a trim plus an append, so it is reinstalled instead.
738
+ let fellOut = installed.firstIndex { $0.queueItemId == wantedIds.first }
739
+ let rotatedPastLeading = fellOut != nil && leading != nil && fellOut! > leading!
740
+ let retained = (forcingReinstall || rotatedPastLeading)
741
+ ? []
742
+ : fellOut.map { Array(installed[$0...]) } ?? []
743
+ let keep = QueueMutationArithmetic.commonPrefixLength(
744
+ current: retained.map { $0.queueItemId }, desired: wantedIds)
745
+
746
+ // Already holding exactly this run: touching it would only throw away the
747
+ // buffer it has built.
748
+ if keep == wantedIds.count, keep == retained.count, fellOut == 0, anchor == 0 {
749
+ return
750
+ }
751
+
752
+ // Nothing worth keeping — install the run whole.
753
+ guard keep > 0 else {
754
+ reinstallWholeSlice(
755
+ on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
756
+ return
757
+ }
758
+
759
+ // The divergent tail must not contain the item being heard. Removing it
760
+ // replaces the leading leg's current item, which stops playback and
761
+ // restores no rate, so the engine sits paused with the transport still
762
+ // wanting to play. Installing the run whole is the destructive option, but
763
+ // it is the one that puts the rate back.
764
+ //
765
+ // `retained` is `installed[fellOut...]`, so the kept prefix ends at
766
+ // `installed[fellOut + keep]` and the tail is everything from there on.
767
+ let tailStart = (fellOut ?? 0) + keep
768
+ guard leading.map({ $0 < tailStart }) ?? true else {
769
+ reinstallWholeSlice(
770
+ on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
771
+ return
772
+ }
773
+
774
+ // Cancel before releasing: `AVURLAsset.dealloc` blocks the thread that
775
+ // releases it while it cancels in-flight key loads, and that thread is the
776
+ // one serialising player state. Every wholesale path already does this;
777
+ // the surgical trim has to as well or a window slide that drops a
778
+ // still-loading remote item stalls the queue behind it.
779
+ let dropped = Array(installed[..<(fellOut ?? 0)]) + Array(retained[keep...])
780
+ AVQueueBuilder.cancelLoading(dropping: dropped, keeping: Array(retained[..<keep]))
781
+ if let fellOut {
782
+ for item in installed[..<fellOut] {
783
+ engine.remove(item)
784
+ }
785
+ }
786
+ for item in retained[keep...] {
787
+ engine.remove(item)
788
+ }
789
+ guard keep < wantedIds.count else { return }
790
+ let tail = AVQueueBuilder.buildPlayerItems(
791
+ tracks: Array(wantedTracks[keep...]), queueItemIds: Array(wantedIds[keep...]),
792
+ config: self.config, server: self.routingServer)
793
+ guard !tail.isEmpty else { return }
794
+ if !engine.insertItems(tail, after: retained[keep - 1]) {
795
+ // The engine refused the surgical insert, so install the whole run
796
+ // instead. Rebuilding the tail's items a second time here is the cost of
797
+ // a path that only runs when the engine has already said no.
798
+ reinstallWholeSlice(
799
+ on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
800
+ }
801
+ }
802
+
803
+ /// Drain the engine and install the whole run, seated on the current
804
+ /// position.
805
+ ///
806
+ /// The only path that seats a fresh current item, which is why the
807
+ /// gapless-flip rearm lives here: arming it on a rebuild that installs
808
+ /// nothing would leave the one-shot armed with no `\.readyToPlay` coming to
809
+ /// disarm it, and stalling would stay on for the rest of the track.
810
+ private func reinstallWholeSlice(
811
+ on engine: PlaybackEngine, slice: [TrackItem], sliceIds: [String],
812
+ startingAt currentId: String
813
+ ) {
814
+ rearmGaplessFlip()
815
+ let items = AVQueueBuilder.buildPlayerItems(
816
+ tracks: slice, queueItemIds: sliceIds,
817
+ config: self.config, server: self.routingServer)
818
+ guard let startIndex = Self.seatIndex(in: items, sliceIds: sliceIds, currentId: currentId)
819
+ else {
820
+ // Nothing at or after the current position could be built. Seating on
821
+ // the trail would replay a track already heard; installing nothing
822
+ // leaves the engine drained, which is what the end verdict reads.
823
+ engine.removeAllItems()
824
+ return
825
+ }
826
+ // Reinstalling the run the engine is already rendering from would restart
827
+ // the track being heard, so carry its playhead across. Only when the item
828
+ // the run seats on is that same one: any other start item is a track
829
+ // change, which begins at zero.
830
+ let position =
831
+ items[startIndex].queueItemId == engine.leadingQueueItemId
832
+ ? engine.currentPositionSeconds : 0
833
+ engine.setItems(
834
+ items, startIndex: startIndex, startPositionSeconds: position)
835
+ }
836
+
837
+ /// Where in a built run the engine starts, or nil when nothing at or after
838
+ /// the current position could be built.
839
+ ///
840
+ /// The run can start behind the playhead, so the engine is told where in it
841
+ /// to begin. Resolved against the built items rather than the positions: an
842
+ /// item whose URL cannot be built is dropped, which shifts everything after
843
+ /// it. When the current position is one of the dropped ones, the next
844
+ /// position in the run that did build is the seat — playing forwards from an
845
+ /// unplayable track, rather than dropping back into the trail behind it.
846
+ internal static func seatIndex(
847
+ in items: [AVPlayerItem], sliceIds: [String], currentId: String
848
+ ) -> Int? {
849
+ if let exact = items.firstIndex(where: { $0.queueItemId == currentId }) {
850
+ return exact
851
+ }
852
+ guard let curOffset = sliceIds.firstIndex(of: currentId) else { return nil }
853
+ let ahead = Set(sliceIds[curOffset...])
854
+ return items.firstIndex { item in item.queueItemId.map(ahead.contains) ?? false }
855
+ }
856
+
857
+ /// Destructive shuffle. Mutates the current queue in-place via
858
+ /// Fisher-Yates, resets `currentTrackIndex` to 0, and resumes
859
+ /// playback on the new tracks[0] if the player was playing
860
+ /// pre-shuffle. Returns the post-shuffle snapshot so the consumer
861
+ /// app can immediately reflect the new ordering in its UI.
862
+ ///
863
+ /// The pre-shuffle ordering is not preserved; once shuffled the
864
+ /// queue stays in the new order until the consumer calls
865
+ /// `setQueue` to install a fresh ordering.
866
+ ///
867
+ /// Empty queue → returns `{ tracks: [], currentIndex: -1, currentTrack: nil }`
868
+ /// without mutating state.
869
+ func shuffleQueue() throws -> Promise<ShuffleResult> {
870
+ // Enqueued at call time like every other queue mutation, so a mutation
871
+ // issued straight afterwards resolves against the shuffled order.
872
+ return enqueueValue { () -> ShuffleResult in
873
+ var result = ShuffleResult(
874
+ tracks: [], currentIndex: -1, currentTrack: nil)
875
+ // Capture playing state BEFORE the mutation so we know whether to
876
+ // resume after the rebuild. The transport's intent rather than the
877
+ // engine's audible state: a leg that is buffering, or mid-crossfade
878
+ // with its outgoing item finished, reports not-playing while the
879
+ // consumer is still playing.
880
+ let wasPlaying = self.wantsToPlay
881
+
882
+ if self.tracks.isEmpty {
883
+ // Empty queue — leave state untouched, return the empty snapshot.
884
+ return result
885
+ }
886
+ guard self.engine != nil else {
887
+ // No engine attached (configure() not yet called). Bail with
888
+ // empty result rather than silently mutating tracks but not
889
+ // the player — the prior shape returned a "shuffled"
890
+ // snapshot while leaving self.tracks unchanged, which lied
891
+ // to the consumer. Gate on `self.engine` (canonical lib-
892
+ // initialised sentinel); `self.player` is gapless-only and
893
+ // nil under CrossfadeEngine.
894
+ return result
895
+ }
896
+
897
+ // Shuffle positions rather than tracks: the permutation is what
898
+ // onQueueChange reports, and it cannot be recovered from the reordered
899
+ // list afterwards. Fisher-Yates via Swift stdlib (Apple-documented).
900
+ var order = Array(self.tracks.indices)
901
+ order.shuffle()
902
+ let newTracks = order.map { self.tracks[$0] }
903
+
904
+ self.performingMutation {
905
+ // Mirrors setQueue — `replaceAll` regenerates queueItemIds
906
+ // so each new AVPlayerItem gets a unique lib-internal
907
+ // identity and the destructive shuffle re-keys every
908
+ // position.
909
+ let newIds = self.queueState.replaceAll(newTracks)
910
+ self.currentTrackIndex = 0
911
+ self.pendingTrackChangeReason = .queueReplaced
912
+ // Re-keyed and re-seated at 0: not over, whatever it was before.
913
+ self.reachedQueueEnd = false
914
+ let attempts = self.effectiveAutoRetries()
915
+ self.retryAttemptsRemaining = Dictionary(
916
+ uniqueKeysWithValues: newIds.map { ($0, attempts) }
917
+ )
918
+ self.fullRebuildPlayerQueue()
919
+ }
920
+ self.rescheduleLookahead()
921
+ self.recomputeCapabilities()
922
+ // Skip a single-track shuffle — the order can't change.
923
+ if self.tracks.count > 1 { self.emitQueueChange(.shuffle, order: order) }
924
+
925
+ // Resume playback if the user was playing before the shuffle.
926
+ // Stays paused otherwise — consumer can call play() if they
927
+ // want to start from the new track 0.
928
+ if wasPlaying, !self.isInterrupted {
929
+ self.pendingStateChangeReason = .user
930
+ self.beginPlayback()
931
+ }
932
+
933
+ result = ShuffleResult(
934
+ tracks: self.tracks,
935
+ currentIndex: 0,
936
+ currentTrack: self.tracks[0])
937
+ return result
938
+ }
939
+ }
940
+ }