react-native-queue-player 1.2.0 → 2.0.1

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