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
@@ -16,17 +16,20 @@ import Foundation
16
16
  /// `TrackPlayer` is the canonical position; the engine
17
17
  /// advances the standby leg's currentItem to the next queue
18
18
  /// position ahead of fade-start.
19
- /// - Equal-power curve approximated via N short linear
20
- /// `setVolumeRamp` sub-ramps. `setVolumeRamp` is linear-only
21
- /// per the AVFoundation API; ~50 sub-ramps over a fade window
19
+ /// - Equal-power curve approximated via N short `setVolumeRamp`
20
+ /// sub-ramps. AVFoundation does not document the ramp's curve, so
21
+ /// each sub-ramp is treated as linear; ~50 sub-ramps over a fade window
22
22
  /// is auditorily indistinguishable from true `cos(t·π/2)` /
23
23
  /// `sin(t·π/2)`.
24
24
  /// - Standby preroll via `AVPlayer.preroll(atRate: 1.0)` ~500ms
25
25
  /// before fade-start so the audio output graph is hot when
26
26
  /// its volume ramp begins.
27
- /// - Backward seek through a scheduled ramp window corrupts the
28
- /// mix per Wade Tregaskis's `setVolumeRamp` findings
29
- /// recovery is `audioMix = nil` + `volume = userVolume`.
27
+ /// - Workaround: a backward seek through a scheduled `setVolumeRamp`
28
+ /// window leaves the mix in place and the volume wrong on the next
29
+ /// play-through; recovery is `audioMix = nil` + `volume = userVolume`.
30
+ /// TODO: monitor upstream for fixes —
31
+ /// https://wadetregaskis.com/fading-audio-with-avplayer/ documents the
32
+ /// symptom; there is no Apple Feedback number to track.
30
33
  /// - Per-boundary route check via `OutputRouteMonitor`: if the
31
34
  /// active route is `.airPlay` / `.carAudio`, the boundary
32
35
  /// hard-cuts (no fade) for that transition. Mid-fade route
@@ -42,8 +45,8 @@ final class CrossfadeEngine: PlaybackEngine {
42
45
 
43
46
  /// User-set fade duration captured at construction time. Both
44
47
  /// `AVAudioMix` ramp scheduling and the role-swap timer
45
- /// coordinate against this value; runtime duration changes
46
- /// require a fresh engine instance via `handoff`.
48
+ /// coordinate against this value; runtime duration changes go
49
+ /// through `setPlaybackMode`, which constructs a new engine.
47
50
  private let crossfadeDurationSeconds: TimeInterval
48
51
 
49
52
  /// Pre-fade preroll lead so the standby leg's audio output
@@ -51,14 +54,24 @@ final class CrossfadeEngine: PlaybackEngine {
51
54
  /// platform research recommendation.
52
55
  static let prerollLeadSeconds: TimeInterval = 0.5
53
56
 
57
+ /// Backstop for the part of the arm chain the status wait does not cover:
58
+ /// the incoming asset's `tracks` load and preroll, which begin once the
59
+ /// standby leg reports ready and have no bound of their own.
60
+ ///
61
+ /// Measured from that flip rather than from the arm, because an arm fires at
62
+ /// most `maxCrossfadeMs + prerollLeadSeconds` before the leading track ends —
63
+ /// a deadline measured from the arm and longer than that could only ever
64
+ /// expire after the boundary it exists to protect.
65
+ static let armDeadlineSeconds: TimeInterval = 5.0
66
+
54
67
  /// Equal-power approximation step count. ~50 sub-ramps over a
55
68
  /// 6-second fade is below the audibility threshold for the
56
69
  /// difference between staircased and continuous cos/sin.
57
70
  static let subRampCount: Int = 50
58
71
 
59
72
  /// Volume multiplier applied to both legs during the fade.
60
- /// Tracked so manual `setVolume(v)` survives a subsequent
61
- /// fade cancel / handoff.
73
+ /// Tracked so manual `setVolume(v)` survives a fade cancel or the
74
+ /// role swap at fade end.
62
75
  private var userVolume: Float = 1.0
63
76
 
64
77
  /// Tracked so the user's playback speed survives a crossfade
@@ -99,6 +112,20 @@ final class CrossfadeEngine: PlaybackEngine {
99
112
 
100
113
  // MARK: - Queue model
101
114
 
115
+ /// The queue the owner serialises player access on. Every callback this
116
+ /// engine delivers, and every timer and observer it installs, lands here —
117
+ /// the fade state below carries no lock, so it is only safe while one queue
118
+ /// owns both it and the transport calls driving it.
119
+ private let ownerQueue: DispatchQueue
120
+
121
+ /// Run `body` on the owner's queue and await its result, for the points
122
+ /// where an async fade step has to touch that state again.
123
+ private func onOwnerQueue<T>(_ body: @escaping () -> T) async -> T {
124
+ await withCheckedContinuation { (cont: CheckedContinuation<T, Never>) in
125
+ self.ownerQueue.async { cont.resume(returning: body()) }
126
+ }
127
+ }
128
+
102
129
  /// Engine-internal queue snapshot — caller sets via
103
130
  /// `setItems`; engine advances `currentIndex` on user skip /
104
131
  /// auto-advance. Each `AVPlayer` instance holds at most one
@@ -121,7 +148,9 @@ final class CrossfadeEngine: PlaybackEngine {
121
148
  /// `NSInvalidArgumentException` when called on a different
122
149
  /// instance than the one that issued the token, so the player
123
150
  /// is captured alongside the token. Removed on pause / stop /
124
- /// seek / skip / role-swap / release; re-installed on play.
151
+ /// release / removeAllItems, and by setItems / remove when they drain
152
+ /// the engine; re-installed on play; stopped and restarted across
153
+ /// `completeCrossfade`.
125
154
  private var armingObserver: (player: AVPlayer, token: Any)?
126
155
 
127
156
  /// `true` after [armCrossfade] has prerolled standby for the
@@ -134,21 +163,11 @@ final class CrossfadeEngine: PlaybackEngine {
134
163
  /// against a fresh fade.
135
164
  private var fadeCompletionTask: Task<Void, Never>?
136
165
 
137
- /// Audio-mix provider supplied by `Equalizer` (and future
138
- /// `Visualizer`). Per-AVPlayerItem `AVAudioMix` is built
139
- /// per-item via the provider's `audioMix(for:)`.
166
+ /// Audio-mix provider set via `setAudioMixProvider` the
167
+ /// `AudioTapProvider` shared by `Equalizer` and `Visualizer`. Each
168
+ /// AVPlayerItem's `AVAudioMix` comes from its `audioMix(for:)`.
140
169
  private var mixProvider: AudioMixProvider?
141
170
 
142
- /// Resolves a track's source URL to its lookahead-cache local file
143
- /// URL when fully cached, else nil. Supplied by `TrackPlayer` (reads
144
- /// `LookaheadCache.cachedURL(forUrl:)` lazily so it always sees the
145
- /// current cache). The engine re-resolves at every late item attach
146
- /// (skip cross-attach, fade-arm preroll) so a track that finished
147
- /// caching AFTER its queue item was first built from the remote URL
148
- /// is played from the local file — instead of re-loading the remote
149
- /// asset, whose `.tracks` round-trip can fail the now-playing format
150
- /// extractor.
151
- private var cachedURLResolver: ((String) -> URL?)?
152
171
 
153
172
  /// Released-once flag so `release()` is idempotent.
154
173
  private var released: Bool = false
@@ -169,10 +188,27 @@ final class CrossfadeEngine: PlaybackEngine {
169
188
  /// the leading-leg error path.
170
189
  private var standbyReadyTimeout: DispatchWorkItem?
171
190
 
191
+ /// Deadline on the whole arm chain, not just the wait for the standby leg's
192
+ /// status.
193
+ ///
194
+ /// `standbyReadyTimeout` is cancelled the moment the status flips, and the
195
+ /// work after that — loading the incoming asset's `tracks` key — has no
196
+ /// bound of its own. Without this deadline a remote asset that never
197
+ /// resolves leaves `standbyArmed` set, and the engine work deferred on
198
+ /// that flag never flushes.
199
+ private var armDeadline: DispatchWorkItem?
200
+
201
+ /// Whether volume ramps have actually been written onto either leg's mix
202
+ /// parameters. Only a written ramp can leave the mix in the state
203
+ /// `cancelFadeInternal`'s reset recovers from, and being armed is not the
204
+ /// same thing: the chain can be armed and still waiting for the incoming
205
+ /// asset, with nothing written yet.
206
+ private var rampsScheduled = false
207
+
172
208
  /// Generation counter incremented at every [awaitStandbyReady]
173
209
  /// entry. Closures captured by the KVO observer + the timeout
174
210
  /// `DispatchWorkItem` carry their generation value — if a new
175
- /// arm cycle increments the counter while a stale main-async
211
+ /// arm cycle increments the counter while a stale `ownerQueue`
176
212
  /// hop is pending, the gate inside the closure short-circuits.
177
213
  /// Defends against a narrow race where the original arm's
178
214
  /// `.readyToPlay` callback fires AFTER `cancelFadeInternal` +
@@ -183,7 +219,7 @@ final class CrossfadeEngine: PlaybackEngine {
183
219
 
184
220
  /// `\.timeControlStatus` KVO on the leading leg + the player it
185
221
  /// was registered against. Re-installed on every role swap.
186
- /// Fires `engineStateMaybeChanged` + `engineIsPlayingChanged`.
222
+ /// Fires `engineStateMaybeChanged`.
187
223
  private var leadingTimeControlObserver: NSKeyValueObservation?
188
224
 
189
225
  /// `\.currentItem.tracks` KVO on the leading leg's currentItem.
@@ -195,6 +231,15 @@ final class CrossfadeEngine: PlaybackEngine {
195
231
  /// Routes `.failed` through `engineDidFailWithError`.
196
232
  private var leadingItemStatusObserver: NSKeyValueObservation?
197
233
 
234
+ /// `\.isPlaybackBufferEmpty` / `\.isPlaybackLikelyToKeepUp` /
235
+ /// `\.isPlaybackBufferFull` KVO on the leading leg's currentItem. Each fires
236
+ /// `engineBufferStateMaybeChanged` so a fast local reload's empty→full edge
237
+ /// reaches the discrete buffer-state surface — the gapless engine gets the
238
+ /// same from its own item KVO. Re-installed on role swap and on `setItems`.
239
+ private var leadingItemBufferEmptyObserver: NSKeyValueObservation?
240
+ private var leadingItemBufferKeepUpObserver: NSKeyValueObservation?
241
+ private var leadingItemBufferFullObserver: NSKeyValueObservation?
242
+
198
243
  /// Block-based `NotificationCenter` observer tokens for the
199
244
  /// AVPlayerItem lifecycle notifications. Filtered inside the
200
245
  /// block to leading-leg only. Removed in `release`.
@@ -203,12 +248,13 @@ final class CrossfadeEngine: PlaybackEngine {
203
248
 
204
249
  // MARK: - Init
205
250
 
206
- init(crossfadeDurationSeconds: TimeInterval) {
251
+ init(crossfadeDurationSeconds: TimeInterval, confinedTo ownerQueue: DispatchQueue) {
207
252
  precondition(
208
253
  crossfadeDurationSeconds >= 0,
209
254
  "crossfadeDurationSeconds must be non-negative"
210
255
  )
211
256
  self.crossfadeDurationSeconds = crossfadeDurationSeconds
257
+ self.ownerQueue = ownerQueue
212
258
 
213
259
  let p1 = AVPlayer()
214
260
  p1.automaticallyWaitsToMinimizeStalling = true
@@ -228,9 +274,8 @@ final class CrossfadeEngine: PlaybackEngine {
228
274
  // doesn't arbitrarily silence one of the two players. The
229
275
  // outgoing leg snaps to userVolume; the standby drops its
230
276
  // item.
231
- DispatchQueue.main.async { [weak self] in
232
- self?.cancelFade()
233
- }
277
+ guard let self else { return }
278
+ self.ownerQueue.async { self.cancelFade() }
234
279
  }
235
280
 
236
281
  // Lib-wide notifications fire for any AVPlayerItem; the
@@ -239,46 +284,27 @@ final class CrossfadeEngine: PlaybackEngine {
239
284
  itemDidPlayToEndToken = NotificationCenter.default.addObserver(
240
285
  forName: .AVPlayerItemDidPlayToEndTime,
241
286
  object: nil,
242
- queue: .main
287
+ // `nil` delivers on the posting thread — AVFoundation's, not ours — so
288
+ // the hop onto the owner's queue is explicit below.
289
+ queue: nil
243
290
  ) { [weak self] note in
244
- self?.handleItemDidPlayToEnd(note)
291
+ guard let self else { return }
292
+ self.ownerQueue.async { self.handleItemDidPlayToEnd(note) }
245
293
  }
246
294
  itemFailedToPlayToEndToken = NotificationCenter.default.addObserver(
247
295
  forName: .AVPlayerItemFailedToPlayToEndTime,
248
296
  object: nil,
249
- queue: .main
297
+ // `nil` delivers on the posting thread — AVFoundation's, not ours — so
298
+ // the hop onto the owner's queue is explicit below.
299
+ queue: nil
250
300
  ) { [weak self] note in
251
- self?.handleItemFailedToPlayToEnd(note)
301
+ guard let self else { return }
302
+ self.ownerQueue.async { self.handleItemFailedToPlayToEnd(note) }
252
303
  }
253
-
254
- // iOS 26+ AVRoutingPlaybackArbiter would let the engine name
255
- // the preferred participant for non-mixable routes (AirPlay
256
- // 2 / wireless CarPlay) so iOS doesn't arbitrarily silence
257
- // one of two playing AVPlayer instances during the
258
- // engagement transient. The current iOS SDK in this build
259
- // ships AVRoutingPlaybackArbiter but the
260
- // `preferredParticipantForNonMixableAudioRoutes` property
261
- // marker is unavailable on iOS at the SDK header level —
262
- // tracked as a follow-up once the SDK exposes it on iOS or
263
- // an alternative API surface lands.
264
- }
265
-
266
- /// No-op placeholder — see init comment for the iOS 26+
267
- /// AVRoutingPlaybackArbiter follow-up.
268
- private func updateRoutingArbiter() {
269
- // Intentional no-op pending SDK availability.
270
304
  }
271
305
 
272
306
  // MARK: - Lifecycle
273
307
 
274
- func prepare(
275
- items: [AVPlayerItem],
276
- startIndex: Int,
277
- startPositionSeconds: TimeInterval
278
- ) async {
279
- setItems(items, startIndex: startIndex, startPositionSeconds: startPositionSeconds)
280
- }
281
-
282
308
  func release() {
283
309
  guard !released else { return }
284
310
  released = true
@@ -299,6 +325,7 @@ final class CrossfadeEngine: PlaybackEngine {
299
325
  itemFailedToPlayToEndToken = nil
300
326
  }
301
327
  routeMonitor.stop()
328
+ AVQueueBuilder.cancelLoading(dropping: queue)
302
329
  leadingPlayer.replaceCurrentItem(with: nil)
303
330
  standbyPlayer.replaceCurrentItem(with: nil)
304
331
  queue.removeAll()
@@ -314,11 +341,24 @@ final class CrossfadeEngine: PlaybackEngine {
314
341
  startIndex: Int,
315
342
  startPositionSeconds: TimeInterval
316
343
  ) {
344
+ // `replaceCurrentItem` below drops the leading leg to rate 0, so a
345
+ // rebuild issued mid-playback would silently pause. Capture the intent
346
+ // and restore the rate after the new item is seated — the same contract
347
+ // `GaplessEngine.setItems` upholds across its own rebuild. The run
348
+ // resumes exactly when a `play()` is still standing over it.
349
+ let shouldResume = wantsPlayback
317
350
  cancelFadeInternal()
351
+ // The outgoing slice is discarded here. Anything the incoming set reuses
352
+ // keeps its loads — a cancelled asset never becomes playable, and this
353
+ // engine deliberately rebuilds items on reused assets at seek, remove
354
+ // and fade-arm.
355
+ AVQueueBuilder.cancelLoading(dropping: queue, keeping: items)
318
356
  queue = items
319
357
  teardownLeadingObservers()
320
358
  if items.isEmpty || startIndex < 0 || startIndex >= items.count {
321
359
  currentIndex = -1
360
+ stopArmingObserver()
361
+ leadingPlayer.pause()
322
362
  leadingPlayer.replaceCurrentItem(with: nil)
323
363
  standbyPlayer.replaceCurrentItem(with: nil)
324
364
  return
@@ -335,8 +375,10 @@ final class CrossfadeEngine: PlaybackEngine {
335
375
  completionHandler: nil
336
376
  )
337
377
  }
378
+ if shouldResume {
379
+ resumeLeadingRespectingTap()
380
+ }
338
381
  installLeadingObservers()
339
- updateRoutingArbiter()
340
382
  delegate?.engineDidTransitionTrack(self)
341
383
  }
342
384
 
@@ -376,12 +418,24 @@ final class CrossfadeEngine: PlaybackEngine {
376
418
  currentIndex = queue.count - 1
377
419
  }
378
420
  if currentIndex < 0 {
421
+ stopArmingObserver()
422
+ leadingPlayer.pause()
379
423
  leadingPlayer.replaceCurrentItem(with: nil)
380
424
  } else {
381
- let next = queue[currentIndex]
425
+ let shouldResume = wantsPlayback
426
+ teardownLeadingObservers()
427
+ // Never the stored instance: the slot that shifts in here may be the
428
+ // item the arm attached to the standby leg, and attaching an item
429
+ // still associated with another player raises an exception Swift
430
+ // cannot catch.
431
+ let next = rebuiltItem(at: currentIndex)
382
432
  applyMixIfNeeded(to: next)
383
433
  applyPitchAlgorithm(to: next)
384
434
  leadingPlayer.replaceCurrentItem(with: next)
435
+ if shouldResume {
436
+ resumeLeadingRespectingTap()
437
+ }
438
+ installLeadingObservers()
385
439
  }
386
440
  } else if idx < currentIndex {
387
441
  currentIndex -= 1
@@ -389,7 +443,15 @@ final class CrossfadeEngine: PlaybackEngine {
389
443
  }
390
444
 
391
445
  func removeAllItems() {
446
+ // Nothing left to want: an intent left standing here resumes the next run
447
+ // installed rather than waiting to be asked.
448
+ stopArmingObserver()
449
+ // Detaching the item does not stop the player — `replaceCurrentItem(with:
450
+ // nil)` leaves the rate where it was, so the next item seated on this leg
451
+ // would start on contact. Draining parks it.
452
+ leadingPlayer.pause()
392
453
  cancelFadeInternal()
454
+ AVQueueBuilder.cancelLoading(dropping: queue)
393
455
  queue.removeAll()
394
456
  currentIndex = -1
395
457
  leadingPlayer.replaceCurrentItem(with: nil)
@@ -406,9 +468,12 @@ final class CrossfadeEngine: PlaybackEngine {
406
468
  // MARK: - Transport
407
469
 
408
470
  func play() {
409
- // Resume at the user's speed (AVPlayer.play() resets rate to 1.0).
410
- leadingPlayer.rate = userPlaybackSpeed
471
+ // Start the arming observer first so `wantsPlayback` is true before the
472
+ // leading `.readyToPlay` observer might resume a leg held for its tap.
473
+ // `resumeLeadingRespectingTap` restores the user's speed (AVPlayer.play()
474
+ // would reset rate to 1.0).
411
475
  startArmingObserver()
476
+ resumeLeadingRespectingTap()
412
477
  }
413
478
 
414
479
  func pause() {
@@ -435,16 +500,16 @@ final class CrossfadeEngine: PlaybackEngine {
435
500
 
436
501
  func seek(toIndex index: Int, position: TimeInterval) {
437
502
  guard index >= 0, index < queue.count else { return }
438
- // Capture playing intent before the fade-cancel: a cross-attach
503
+ // Read playing intent before the fade-cancel: a cross-attach
439
504
  // (replaceCurrentItem) drops the leading player to rate 0, so the
440
505
  // new leg must be resumed at the user's speed below to match the
441
506
  // gapless skip (which restores rate across its queue rebuild).
442
- let wasPlaying = isPlaying
507
+ let shouldResume = wantsPlayback
443
508
  // Seek is itself a track-change: the engineDidTransitionTrack
444
509
  // fire at the end of this method is the authoritative active-
445
510
  // track flip for the new index. Suppress the cancel-revert
446
511
  // emit so a mid-fade seek doesn't dispatch an intermediate
447
- // engineCrossfadeDidCancel(revertedIndex: outgoing) event
512
+ // `engineCrossfadeDidCancel` (a revert to the outgoing leg)
448
513
  // before the seek's own track-change lands.
449
514
  cancelFadeInternal(emitRevert: false)
450
515
  teardownLeadingObservers()
@@ -466,9 +531,13 @@ final class CrossfadeEngine: PlaybackEngine {
466
531
  // zero to honour the seek contract.
467
532
  existing.seek(to: .zero, completionHandler: nil)
468
533
  }
534
+ // No re-attach here, but an item that reached its end has parked its leg
535
+ // at rate 0, and seeking back into it must play from where it lands.
536
+ if shouldResume {
537
+ resumeLeadingRespectingTap()
538
+ }
469
539
  standbyArmed = false
470
540
  installLeadingObservers()
471
- updateRoutingArbiter()
472
541
  delegate?.engineDidTransitionTrack(self)
473
542
  return
474
543
  }
@@ -486,20 +555,17 @@ final class CrossfadeEngine: PlaybackEngine {
486
555
  // queueItemId across so TrackPlayer.matchTrackIndex can still
487
556
  // resolve the new item back to its queue position.
488
557
  let target = AVPlayerItem(
489
- // Prefer the lookahead cache's local file when the track has
490
- // cached since the queue item was built from its remote URL —
491
- // otherwise this fresh item re-loads the remote asset and the
492
- // `.tracks` round-trip can fail the now-playing format extractor.
493
- asset: cacheResolvedAsset(for: existing),
494
- // Mirror AVQueueBuilder.makeItem fully — including "tracks". A forward
495
- // skip to a cached track rebuilds the incoming leg here; without the
496
- // tracks key auto-loading, the `.new`-only tracks KVO never fires for
497
- // the fresh item and the now-playing format never resolves.
498
- automaticallyLoadedAssetKeys: ["playable", "duration", "tracks"]
558
+ asset: existing.asset,
559
+ automaticallyLoadedAssetKeys: ["playable", "duration"]
499
560
  )
500
561
  target.preferredForwardBufferDuration = 0.0
501
562
  target.queueItemId = existing.queueItemId
502
563
  target.sourceURL = existing.sourceURL
564
+ target.suppliedReplayGain = existing.suppliedReplayGain
565
+ // The routing stamp lives on the item instance (associated object), not the
566
+ // reused asset, so carry it too — without it source classification reads the
567
+ // default `false` and reports a cache-served track as `.streaming`.
568
+ target.isRoutedThroughReadThroughServer = existing.isRoutedThroughReadThroughServer
503
569
  // Replace the queue slot with the rebuilt item — any cached
504
570
  // AVPlayerItem reference held by a future callsite (e.g. an
505
571
  // armCrossfade re-arming on this index) must read queue[index]
@@ -515,15 +581,14 @@ final class CrossfadeEngine: PlaybackEngine {
515
581
  completionHandler: nil
516
582
  )
517
583
  }
518
- if wasPlaying {
519
- // replaceCurrentItem leaves the player at rate 0 — resume at the
520
- // user's speed (mirrors play() / completeCrossfade) so a skip while
521
- // playing at a non-default speed doesn't fall back to 1.0x.
522
- leadingPlayer.rate = userPlaybackSpeed
584
+ if shouldResume {
585
+ // replaceCurrentItem leaves the player at rate 0 — resume respecting a
586
+ // wanted tap (mirrors play() / seek) so a skip while playing at a
587
+ // non-default speed doesn't fall back to 1.0x and the tap survives.
588
+ resumeLeadingRespectingTap()
523
589
  }
524
590
  standbyArmed = false
525
591
  installLeadingObservers()
526
- updateRoutingArbiter()
527
592
  delegate?.engineDidTransitionTrack(self)
528
593
  }
529
594
 
@@ -610,9 +675,12 @@ final class CrossfadeEngine: PlaybackEngine {
610
675
  }
611
676
 
612
677
  var isPlaying: Bool {
613
- // Reads the leading leg unconditionally (both legs play mid-fade); the
614
- // `timeControlStatus` accessor below deliberately reads the active leg
615
- // (standby while fading) to match `currentMediaItem` for state derivation.
678
+ // Whether the outgoing leg is audible right now not whether the engine
679
+ // was asked to play. It reads false mid-fade once the leading leg's item
680
+ // has ended, and false while a leg is buffering, so a caller deciding
681
+ // whether to restore a rate wants `wantsPlayback` instead. The
682
+ // `timeControlStatus` accessor below reads the active leg (standby while
683
+ // fading) to match `currentMediaItem` for state derivation.
616
684
  leadingPlayer.timeControlStatus == .playing
617
685
  }
618
686
 
@@ -629,6 +697,24 @@ final class CrossfadeEngine: PlaybackEngine {
629
697
  return player.currentItem
630
698
  }
631
699
 
700
+ /// The outgoing leg's item for the whole fade — `currentMediaItem` switches
701
+ /// to the incoming leg as soon as the fade starts, which is the wrong end to
702
+ /// anchor an installed run on.
703
+ var leadingQueueItemId: String? {
704
+ leadingPlayer.currentItem?.queueItemId
705
+ }
706
+
707
+ var isFadePendingOrActive: Bool { standbyArmed || fadeInFlight }
708
+
709
+ /// Both legs while a fade is scheduled or running: the standby leg carries a
710
+ /// prerolled item that is about to be heard, and a mutation that removes it
711
+ /// out from under the arm chain strands the fade on an item the queue no
712
+ /// longer has.
713
+ var attachedQueueItemIds: [String] {
714
+ [leadingPlayer.currentItem?.queueItemId, standbyPlayer.currentItem?.queueItemId]
715
+ .compactMap { $0 }
716
+ }
717
+
632
718
  var allMediaItems: [AVPlayerItem] {
633
719
  queue
634
720
  }
@@ -640,64 +726,29 @@ final class CrossfadeEngine: PlaybackEngine {
640
726
  refreshAllItemMixes()
641
727
  }
642
728
 
643
- func setCachedURLResolver(_ resolver: ((String) -> URL?)?) {
644
- cachedURLResolver = resolver
645
- }
646
-
647
- /// The lookahead cache's local file URL for [item]'s source track,
648
- /// when it has fully cached since the item was built; nil when no
649
- /// resolver is wired, the item carries no source URL, or the cache
650
- /// misses. Keyed by the verbatim `sourceURL` the cache was keyed
651
- /// with never the asset URL (already `file://` once local).
652
- private func cachedLocalURL(for item: AVPlayerItem) -> URL? {
653
- guard let resolver = cachedURLResolver, let url = item.sourceURL else {
654
- return nil
655
- }
656
- return resolver(url)
657
- }
658
-
659
- /// The asset a queue item should be (re)built from at a late attach:
660
- /// the lookahead cache's local file when [existing]'s track has
661
- /// since cached, else [existing]'s own asset. Re-resolving here lets
662
- /// a forward/back skip to a now-cached track play from local disk
663
- /// rather than re-loading the remote asset.
664
- private func cacheResolvedAsset(for existing: AVPlayerItem) -> AVAsset {
665
- // Reuse the existing asset when it's already this cache file (built
666
- // cached at queue-build time) — the cross-attach rebuilds the item
667
- // regardless, so there's nothing to upgrade and a fresh AVURLAsset
668
- // on the same file would just be a wasted allocation.
669
- guard let cachedURL = cachedLocalURL(for: existing),
670
- (existing.asset as? AVURLAsset)?.url != cachedURL else {
671
- return existing.asset
672
- }
673
- return AVURLAsset(url: cachedURL)
674
- }
675
-
676
- /// If [index]'s queue item was built from a remote URL whose track
677
- /// has since fully cached, swap the slot for a fresh item backed by
678
- /// the local cache file (carrying `queueItemId` + `sourceURL` across)
679
- /// and return it; otherwise return the existing item unchanged. Lets
680
- /// the fade-arm preroll pick up a track that cached after the queue
681
- /// was first built so an auto-advance crossfade plays from local disk.
682
- private func cacheUpgradedItem(at index: Int) -> AVPlayerItem {
729
+ /// Resolve the item for [index] as a **fresh** `AVPlayerItem`.
730
+ ///
731
+ /// Never hands back the stored instance. Attaching an `AVPlayerItem` that is
732
+ /// still associated with another `AVPlayer` raises an `NSException` that
733
+ /// Swift cannot catch, and the association is released asynchronously with
734
+ /// no API to observe it so an item that was on a leg a moment ago is not
735
+ /// safe to attach, and there is no way to ask whether it is. `seek(toIndex:)`
736
+ /// rebuilds for the same reason.
737
+ private func rebuiltItem(at index: Int) -> AVPlayerItem {
683
738
  let existing = queue[index]
684
- // Only rebuild on the genuine remote→cached transition. When the slot
685
- // was already built from this cache file (cached at queue-build time),
686
- // reuse the prepared item rather than discarding its buffers for an
687
- // identical rebuild + re-preroll.
688
- guard let cachedURL = cachedLocalURL(for: existing),
689
- (existing.asset as? AVURLAsset)?.url != cachedURL else {
690
- return existing
691
- }
692
- let upgraded = AVPlayerItem(
693
- asset: AVURLAsset(url: cachedURL),
694
- automaticallyLoadedAssetKeys: ["playable", "duration", "tracks"]
739
+ let rebuilt = AVPlayerItem(
740
+ asset: existing.asset,
741
+ automaticallyLoadedAssetKeys: ["playable", "duration"]
695
742
  )
696
- upgraded.preferredForwardBufferDuration = 0.0
697
- upgraded.queueItemId = existing.queueItemId
698
- upgraded.sourceURL = existing.sourceURL
699
- queue[index] = upgraded
700
- return upgraded
743
+ rebuilt.preferredForwardBufferDuration = 0.0
744
+ rebuilt.queueItemId = existing.queueItemId
745
+ rebuilt.sourceURL = existing.sourceURL
746
+ rebuilt.suppliedReplayGain = existing.suppliedReplayGain
747
+ // Carry the read-through routing stamp (an item-instance associated object)
748
+ // so source classification keeps reporting `.cached` across the rebuild.
749
+ rebuilt.isRoutedThroughReadThroughServer = existing.isRoutedThroughReadThroughServer
750
+ queue[index] = rebuilt
751
+ return rebuilt
701
752
  }
702
753
 
703
754
  func refreshAllItemMixes() {
@@ -713,6 +764,10 @@ final class CrossfadeEngine: PlaybackEngine {
713
764
  if let standby = standbyPlayer.currentItem, standby.audioMix == nil {
714
765
  standby.audioMix = mixProvider?.audioMix(for: standby)
715
766
  }
767
+ // `audioMix(for:)` reports the asset it built for, so walking the standby
768
+ // leg last leaves the *upcoming* track standing as the active asset. Say
769
+ // which one is actually audible.
770
+ mixProvider?.noteActiveAsset(leadingPlayer.currentItem?.asset)
716
771
  }
717
772
 
718
773
  private func applyMixIfNeeded(to item: AVPlayerItem) {
@@ -721,6 +776,100 @@ final class CrossfadeEngine: PlaybackEngine {
721
776
  item.audioMix = mix
722
777
  }
723
778
 
779
+ /// Resume the leading leg for a fresh seat / resume, holding it at rate 0
780
+ /// when a consumer wants the tap and the item is not yet ready to render.
781
+ ///
782
+ /// AVPlayer honours `audioMix` only before an item renders; a mix set once
783
+ /// the leg is playing is silently dropped and the equaliser / visualizer tap
784
+ /// never prepares. When a tap is wanted we keep a not-yet-ready leg at rate 0
785
+ /// and let the leading `.readyToPlay` observer load tracks, install the mix,
786
+ /// then resume the held leg (see `installLeadingObservers` and
787
+ /// `installLeadingTapMixThenResume`). When the item is already `.readyToPlay`
788
+ /// the `.new`-only status observer will not fire, so drive that same path
789
+ /// here. The common no-tap path — and any provider without a consumer
790
+ /// model — resumes immediately, unchanged.
791
+ ///
792
+ /// Callers hold a live playback intent. Used for fresh seats and `play()`,
793
+ /// and for the same-instance seek so it cannot start a leg a preceding seat
794
+ /// is holding. NOT used by `completeCrossfade` (it promotes an
795
+ /// already-rendering leg — holding would pause live audio) or the repeat-one
796
+ /// loop (the same already-ready item).
797
+ private func resumeLeadingRespectingTap() {
798
+ let player = leadingPlayer
799
+ guard let item = player.currentItem else {
800
+ player.rate = userPlaybackSpeed
801
+ return
802
+ }
803
+ guard mixProvider?.anyConsumerWantsTap() == true else {
804
+ player.rate = userPlaybackSpeed
805
+ return
806
+ }
807
+ // A deterministically-missing local file never reaches `.readyToPlay`, and
808
+ // `installLeadingObservers` surfaces its failure without installing a
809
+ // status observer — so nothing would resume a held leg. Resume directly:
810
+ // it cannot render a tap anyway, and the failure path drives the retry.
811
+ if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
812
+ !FileManager.default.fileExists(atPath: asset.url.path) {
813
+ player.rate = userPlaybackSpeed
814
+ return
815
+ }
816
+ if item.status == .readyToPlay {
817
+ // Already ready: the `.new`-only status observer will not fire, so drive
818
+ // the tracks-load → mix-install → resume here. The leg is still held at
819
+ // rate 0 (not rendering) until that completes.
820
+ installLeadingTapMixThenResume(item: item)
821
+ return
822
+ }
823
+ // Not yet ready: leave the leg at rate 0. `installLeadingObservers` arms
824
+ // `autoWaits = true` for a not-yet-ready item and its `.readyToPlay`
825
+ // observer installs the mix and then resumes this held leg.
826
+ }
827
+
828
+ /// Load the leading item's `tracks` key, then install its tap-mix and resume
829
+ /// a leg held for the tap. `.readyToPlay` does not guarantee the asset's
830
+ /// `tracks` key is loaded — that is a separate async load — and
831
+ /// `audioMix(for:)` yields nil until it is (the reason the arm path loads
832
+ /// tracks before attaching the standby's mix). Loading first guarantees the
833
+ /// mix builds, so the tap is live before the first sample. The leg stays held
834
+ /// (rate 0 under a live play-intent, `autoWaits` true) until the completion
835
+ /// installs the mix, permits render, and resumes it.
836
+ ///
837
+ /// The load is bounded by `armDeadlineSeconds`, matching the standby arm
838
+ /// deadline: a stall after `.readyToPlay` must not leave the held leg silent
839
+ /// forever, so on timeout (or a load failure) the leg resumes untapped. The
840
+ /// completion installs the mix only while the leg is still held (rate 0):
841
+ /// once it has resumed, AVPlayer drops a mix set on a rendering item and
842
+ /// leaks its tap cascade (see `refreshAllItemMixes`), so a consumer that
843
+ /// subscribed after an untapped seat is left to the format-changed catch-up.
844
+ /// Re-checks the engine is live and the item is still the captured leg's
845
+ /// current one across the load.
846
+ private func installLeadingTapMixThenResume(item: AVPlayerItem) {
847
+ let player = leadingPlayer
848
+ let asset = item.asset
849
+ Task { [weak self] in
850
+ await withTaskGroup(of: Void.self) { group in
851
+ group.addTask { _ = try? await asset.load(.tracks) }
852
+ group.addTask {
853
+ try? await Task.sleep(
854
+ nanoseconds: UInt64(Self.armDeadlineSeconds * 1_000_000_000))
855
+ }
856
+ _ = await group.next()
857
+ group.cancelAll()
858
+ }
859
+ guard let self else { return }
860
+ await self.onOwnerQueue {
861
+ guard !self.released, player.currentItem === item else { return }
862
+ if player.rate == 0 {
863
+ self.refreshAllItemMixes()
864
+ }
865
+ player.automaticallyWaitsToMinimizeStalling = false
866
+ if self.wantsPlayback, player.rate == 0 {
867
+ player.rate = self.userPlaybackSpeed
868
+ }
869
+ }
870
+ }
871
+ }
872
+
724
873
  func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {
725
874
  pitchCorrectionMode = mode
726
875
  reapplyPitchToCurrentLegs()
@@ -744,16 +893,6 @@ final class CrossfadeEngine: PlaybackEngine {
744
893
  if let standby = standbyPlayer.currentItem { applyPitchAlgorithm(to: standby) }
745
894
  }
746
895
 
747
- // MARK: - Handoff
748
-
749
- func handoff(to nextEngine: PlaybackEngine, atTrackBoundary: Bool) async {
750
- // Engine handoff (gapless ↔ crossfade) is driven by
751
- // TrackPlayer.swift's mode-switch path which holds the
752
- // canonical queue model. The engine's own `release()` is
753
- // the caller's responsibility AFTER the swap.
754
- return
755
- }
756
-
757
896
  // MARK: - Equal-power gain math
758
897
 
759
898
  /// Equal-power gain pair for fade progress `t ∈ [0, 1]`.
@@ -821,22 +960,34 @@ final class CrossfadeEngine: PlaybackEngine {
821
960
 
822
961
  // MARK: - Boundary arming
823
962
 
963
+ /// Whether the transport wants playback, independent of which leg is
964
+ /// producing sound.
965
+ ///
966
+ /// The arming observer is the intent: `play()` installs it; `pause()`,
967
+ /// `stop()` and `release()` remove it; every path that drains the engine
968
+ /// removes it, because a run with nothing in it is not a run being played;
969
+ /// and `completeCrossfade` stops and restarts it across the role flip,
970
+ /// synchronously, so no reader sees the gap.
971
+ ///
972
+ /// A leg's rate or status cannot answer this. Mid-fade the outgoing leading
973
+ /// leg has already stopped while the incoming one plays, and an item that
974
+ /// has reached its end leaves its leg at rate 0 with the transport still
975
+ /// wanting to play. Restoring the rate from either of those leaves the
976
+ /// engine paused against the consumer's intent.
977
+ private var wantsPlayback: Bool { armingObserver != nil }
978
+
824
979
  /// Boundary-arming periodic time observer. Watches the leading
825
980
  /// leg's time at 200ms cadence; when within
826
981
  /// `effectiveCrossfadeSeconds + prerollLeadSeconds` of track end,
827
982
  /// prerolls standby + schedules the equal-power ramp on both
828
983
  /// legs' `AVMutableAudioMixInputParameters`.
829
- ///
830
- /// Cancellation: `pause` / `stop` / `seek` / `skipToNext` /
831
- /// `skipToPrevious` / `release` all remove the observer. `play`
832
- /// re-installs it.
833
984
  private func startArmingObserver() {
834
985
  if armingObserver != nil { return }
835
986
  let player = leadingPlayer
836
987
  let interval = CMTime(seconds: 0.2, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
837
988
  let token = player.addPeriodicTimeObserver(
838
989
  forInterval: interval,
839
- queue: .main
990
+ queue: ownerQueue
840
991
  ) { [weak self] _ in
841
992
  self?.maybeArmCrossfade()
842
993
  }
@@ -860,6 +1011,12 @@ final class CrossfadeEngine: PlaybackEngine {
860
1011
  // would advance past the track the user asked to repeat.
861
1012
  if repeatMode == .track { return }
862
1013
  if currentIndex < 0 || currentIndex >= queue.count - 1 { return } // last track or empty
1014
+ // Only a leg that is rendering arms. A rate written onto a leg sitting at
1015
+ // its end — a play() after the queue ended — reports the end position
1016
+ // without moving, and arming there would fade into whatever was queued
1017
+ // behind the finished track. A leg stalled inside the arm window waits
1018
+ // for the tick on which it resumes, so the standby prerolls from then.
1019
+ if leadingPlayer.timeControlStatus != .playing { return }
863
1020
  guard let leading = leadingPlayer.currentItem else { return }
864
1021
  let duration = leading.duration
865
1022
  if !duration.isNumeric { return }
@@ -895,22 +1052,24 @@ final class CrossfadeEngine: PlaybackEngine {
895
1052
  /// wait until the status reaches `.readyToPlay` before invoking
896
1053
  /// `preroll`; `runFade` chains through the readiness callback so
897
1054
  /// the timing semantics (preroll → ramp schedule → wall-clock
898
- /// completion) are preserved. The pre-attach `seek(to: .zero)`
899
- /// on the prior implementation was redundant — `replaceCurrentItem`
900
- /// resets the item's playhead — and was masking the readiness
901
- /// race because seek-on-detached-item is a documented no-op on
902
- /// modern iOS.
1055
+ /// completion) are preserved. No seek before the attach:
1056
+ /// `replaceCurrentItem` resets the item's playhead.
903
1057
  private func armCrossfade(durationSeconds: TimeInterval) {
904
- let nextIdx = currentIndex + 1
905
- guard nextIdx < queue.count else {
1058
+ // Validate the index here rather than trusting the caller's earlier
1059
+ // check: `currentIndex + 1` is a trapping add, so a `currentIndex` of
1060
+ // `Int.max` crashes the process before any bounds check can run.
1061
+ // `NSNotFound` is `Int.max` on 64-bit, so a not-found sentinel that
1062
+ // reaches this field is fatal rather than merely wrong.
1063
+ let idx = currentIndex
1064
+ let count = queue.count
1065
+ guard idx >= 0, idx < count - 1 else {
906
1066
  standbyArmed = false
1067
+ armDeadline?.cancel()
1068
+ armDeadline = nil
907
1069
  return
908
1070
  }
909
- // Re-resolve against the cache: a track that finished caching after
910
- // the queue was built is swapped for its local file here so the
911
- // auto-advance fade plays from local disk, not the remote URL.
912
- let nextItem = cacheUpgradedItem(at: nextIdx)
913
- applyMixIfNeeded(to: nextItem)
1071
+ let nextIdx = idx + 1
1072
+ let nextItem = rebuiltItem(at: nextIdx)
914
1073
  applyPitchAlgorithm(to: nextItem)
915
1074
  let standby = standbyPlayer
916
1075
  standby.replaceCurrentItem(with: nextItem)
@@ -918,6 +1077,9 @@ final class CrossfadeEngine: PlaybackEngine {
918
1077
  awaitStandbyReady(standby) { [weak self] in
919
1078
  guard let self else { return }
920
1079
  if self.released || !self.standbyArmed { return }
1080
+ // The status wait's own timeout is cancelled by the flip that got us
1081
+ // here; everything below is unbounded without this.
1082
+ self.startArmDeadline()
921
1083
  // Explicit `tracks` key load on the freshly-replaced standby
922
1084
  // asset BEFORE preroll. `AVPlayerItem.status == .readyToPlay`
923
1085
  // does NOT guarantee the asset's tracks key is loaded —
@@ -925,32 +1087,50 @@ final class CrossfadeEngine: PlaybackEngine {
925
1087
  // tracks to be present so it can attach the volume-ramp
926
1088
  // audioMix to the standby item.
927
1089
  let nextAsset = nextItem.asset
928
- Task { @MainActor [weak self] in
1090
+ Task { [weak self] in
929
1091
  do {
930
1092
  _ = try await nextAsset.load(.tracks)
931
1093
  } catch {
932
- self?.cancelFadeInternal()
1094
+ if let self { await self.onOwnerQueue { self.cancelFadeInternal() } }
933
1095
  return
934
1096
  }
935
1097
  guard let self else { return }
936
- if self.released || !self.standbyArmed { return }
937
- standby.automaticallyWaitsToMinimizeStalling = false
938
- // Await preroll via a checked continuation so the rest of
939
- // the chain stays on @MainActor. Spawning a fresh Task
940
- // inside `preroll`'s @escaping completion would create a
941
- // @Sendable-closure-captures-non-Sendable-self warning
942
- // because preroll's callback queue is unspecified.
1098
+ // The preroll rate is read on the owner queue with the arm check,
1099
+ // so it is the speed set while the fade was being armed.
1100
+ let (armed, prerollRate) = await self.onOwnerQueue { () -> (Bool, Float) in
1101
+ if self.released || !self.standbyArmed { return (false, 1.0) }
1102
+ // Provider mix goes on here, between the tracks load and the
1103
+ // preroll: `audioMix(for:)` yields nil for an item whose tracks
1104
+ // key isn't loaded, and AVPlayer drops an `audioMix` set once the
1105
+ // item has begun rendering. That leaves this the only window in
1106
+ // which a remote incoming item can pick up the EQ / ReplayGain /
1107
+ // visualizer tap, and `runFade` schedules its ramp onto whatever
1108
+ // input parameters are attached by then. Read the item back off
1109
+ // the leg — the queue slot can be rebuilt across the await.
1110
+ if let attached = standby.currentItem {
1111
+ self.applyMixIfNeeded(to: attached)
1112
+ }
1113
+ standby.automaticallyWaitsToMinimizeStalling = false
1114
+ return (true, self.userPlaybackSpeed)
1115
+ }
1116
+ guard armed else { return }
1117
+ // Await preroll via a checked continuation rather than spawning a
1118
+ // fresh Task inside `preroll`'s @escaping completion, which would
1119
+ // warn about a @Sendable closure capturing non-Sendable self —
1120
+ // preroll's callback queue is unspecified.
943
1121
  _ = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
944
- standby.preroll(atRate: 1.0) { ready in
1122
+ standby.preroll(atRate: prerollRate) { ready in
945
1123
  cont.resume(returning: ready)
946
1124
  }
947
1125
  }
948
- if self.released || !self.standbyArmed { return }
949
- // Best-effort restore: cancel/release paths drop the
950
- // standby item entirely, so a missed restore here is
951
- // bounded by the next replaceCurrentItem.
952
- standby.automaticallyWaitsToMinimizeStalling = priorAutoStall
953
- self.runFade(durationSeconds: durationSeconds)
1126
+ await self.onOwnerQueue {
1127
+ if self.released || !self.standbyArmed { return }
1128
+ // Best-effort restore: cancel/release paths drop the
1129
+ // standby item entirely, so a missed restore here is
1130
+ // bounded by the next replaceCurrentItem.
1131
+ standby.automaticallyWaitsToMinimizeStalling = priorAutoStall
1132
+ self.runFade(durationSeconds: durationSeconds)
1133
+ }
954
1134
  }
955
1135
  }
956
1136
  }
@@ -962,6 +1142,22 @@ final class CrossfadeEngine: PlaybackEngine {
962
1142
  /// `standbyArmed` before acting. Backed by a 5s wall-clock
963
1143
  /// timeout that calls `cancelFadeInternal` if the status never
964
1144
  /// flips (stuck-load defence).
1145
+ /// Bound the arm chain so a standby leg that never becomes playable cannot
1146
+ /// leave the engine armed indefinitely. Generous relative to the ready-wait:
1147
+ /// this is the backstop for everything after it, not a second attempt at the
1148
+ /// same deadline.
1149
+ private func startArmDeadline() {
1150
+ armDeadline?.cancel()
1151
+ let deadline = DispatchWorkItem { [weak self] in
1152
+ guard let self else { return }
1153
+ self.armDeadline = nil
1154
+ guard self.standbyArmed, !self.fadeInFlight else { return }
1155
+ self.cancelFadeInternal()
1156
+ }
1157
+ armDeadline = deadline
1158
+ ownerQueue.asyncAfter(deadline: .now() + Self.armDeadlineSeconds, execute: deadline)
1159
+ }
1160
+
965
1161
  private func awaitStandbyReady(_ player: AVPlayer, ready: @escaping () -> Void) {
966
1162
  standbyReadyObserver?.invalidate()
967
1163
  standbyReadyObserver = nil
@@ -979,10 +1175,10 @@ final class CrossfadeEngine: PlaybackEngine {
979
1175
  let s = obs.status
980
1176
  // `.unknown` is the start state — wait for the first real flip.
981
1177
  guard s == .readyToPlay || s == .failed else { return }
982
- DispatchQueue.main.async { [weak self] in
983
- guard let self else { return }
1178
+ guard let self else { return }
1179
+ self.ownerQueue.async {
984
1180
  // Cross-cycle race guard: if a new armCrossfade cycle
985
- // started after this observer queued its main-async hop,
1181
+ // started after this observer queued its `ownerQueue` hop,
986
1182
  // the generation counter has advanced — this fire belongs
987
1183
  // to a stale standby and must not run `ready()` against
988
1184
  // the new arm's state.
@@ -1011,7 +1207,7 @@ final class CrossfadeEngine: PlaybackEngine {
1011
1207
  self.cancelFadeInternal()
1012
1208
  }
1013
1209
  standbyReadyTimeout = timeout
1014
- DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeout)
1210
+ self.ownerQueue.asyncAfter(deadline: .now() + 5.0, execute: timeout)
1015
1211
  }
1016
1212
 
1017
1213
  /// Input parameters to drive the fade ramp on [item]. Reuses the item's
@@ -1068,9 +1264,24 @@ final class CrossfadeEngine: PlaybackEngine {
1068
1264
  return
1069
1265
  }
1070
1266
 
1267
+ // The leading ramp anchors on the leg's current play position; a
1268
+ // non-numeric CMTime (an unresolved timebase — a leg that reached the fade
1269
+ // before its clock settled, which host load makes more likely) traps
1270
+ // AVFoundation's `setVolumeRamp` on the invalid time range. Abort the fade
1271
+ // cleanly instead. The standby ramp anchors at kCMTimeZero and is exempt.
1272
+ let leadingFadeStart = leading.currentTime()
1273
+ guard leadingFadeStart.isNumeric else {
1274
+ NSLog("[RNQP-CROSSFADE] runFade aborting — leading currentTime not numeric; cancelling fade")
1275
+ cancelFadeInternal()
1276
+ return
1277
+ }
1278
+
1071
1279
  fadeInFlight = true
1280
+ // The chain reached the fade, so its backstop has done its job; the fade
1281
+ // has its own completion path.
1282
+ armDeadline?.cancel()
1283
+ armDeadline = nil
1072
1284
 
1073
- let leadingFadeStart = leading.currentTime()
1074
1285
  let ramps = Self.equalPowerSubRamps(durationSeconds: durationSeconds)
1075
1286
 
1076
1287
  // Reuse each leg's EXISTING tap-bearing input parameters so the EQ /
@@ -1083,6 +1294,7 @@ final class CrossfadeEngine: PlaybackEngine {
1083
1294
  // bare ramp-only params object only when the leg carries no tap (no
1084
1295
  // EQ/viz/RG consumer, HLS, or tracks not yet loaded).
1085
1296
  let leadingParams = fadeParams(for: leading, track: leadingTrack)
1297
+ rampsScheduled = true
1086
1298
  for ramp in ramps {
1087
1299
  let absStart = leadingFadeStart + ramp.timeRange.start
1088
1300
  let timeRange = CMTimeRange(start: absStart, duration: ramp.timeRange.duration)
@@ -1118,7 +1330,10 @@ final class CrossfadeEngine: PlaybackEngine {
1118
1330
  standby.audioMix = standbyMix
1119
1331
 
1120
1332
  standbyPlayer.volume = userVolume // ramp inside the mix handles the actual envelope
1121
- standbyPlayer.play()
1333
+ // Start at the user's speed rather than `play()`'s implicit 1.0, so the
1334
+ // incoming track is not audibly played at normal speed for the length of
1335
+ // the fade and then snapped.
1336
+ standbyPlayer.rate = userPlaybackSpeed
1122
1337
 
1123
1338
  // Active-track flip: the standby leg is now audibly playing
1124
1339
  // (volume 0 → ramping up). Notify the delegate so JS-facing
@@ -1128,8 +1343,7 @@ final class CrossfadeEngine: PlaybackEngine {
1128
1343
  // etc.) also flips at this point because `fadeInFlight = true`
1129
1344
  // is already set above; the delegate sees consistent state
1130
1345
  // across the callback.
1131
- let incomingIdx = currentIndex + 1
1132
- delegate?.engineCrossfadeDidBegin(self, incomingIndex: incomingIdx)
1346
+ delegate?.engineCrossfadeDidBegin(self)
1133
1347
 
1134
1348
  // Post-fade swap scheduled in wall-clock — the asset-time-keyed
1135
1349
  // ramps complete in this many seconds regardless of pause
@@ -1140,13 +1354,14 @@ final class CrossfadeEngine: PlaybackEngine {
1140
1354
  // pauses+resumes within one boundary window.
1141
1355
  fadeCompletionTask?.cancel()
1142
1356
  let durationNs = UInt64(durationSeconds * 1_000_000_000)
1143
- fadeCompletionTask = Task { @MainActor [weak self] in
1357
+ fadeCompletionTask = Task { [weak self] in
1144
1358
  do {
1145
1359
  try await Task.sleep(nanoseconds: durationNs)
1146
1360
  } catch {
1147
1361
  return // cancelled
1148
1362
  }
1149
- self?.completeCrossfade()
1363
+ guard let self else { return }
1364
+ await self.onOwnerQueue { self.completeCrossfade() }
1150
1365
  }
1151
1366
  }
1152
1367
 
@@ -1182,14 +1397,17 @@ final class CrossfadeEngine: PlaybackEngine {
1182
1397
  newLeading.audioMix = nil
1183
1398
  applyMixIfNeeded(to: newLeading)
1184
1399
  }
1185
- // The promoted standby was prerolled + played at rate 1.0; restore
1186
- // the user's playback speed so it survives the transition.
1400
+ // The promoted standby already runs at the user's speed; restamping it
1401
+ // here keeps the promotion total when the speed changed mid-fade.
1187
1402
  leadingPlayer.rate = userPlaybackSpeed
1188
1403
  standbyArmed = false
1404
+ rampsScheduled = false
1189
1405
  currentIndex += 1
1190
1406
  startArmingObserver()
1191
1407
  installLeadingObservers()
1192
- updateRoutingArbiter()
1408
+ // Settled before the transition: work held back for this fade belongs to
1409
+ // the run the transition is about to be told to extend.
1410
+ delegate?.engineFadeDidSettle(self)
1193
1411
  delegate?.engineDidTransitionTrack(self)
1194
1412
  }
1195
1413
 
@@ -1200,44 +1418,75 @@ final class CrossfadeEngine: PlaybackEngine {
1200
1418
  standbyReadyObserver = nil
1201
1419
  standbyReadyTimeout?.cancel()
1202
1420
  standbyReadyTimeout = nil
1421
+ armDeadline?.cancel()
1422
+ armDeadline = nil
1203
1423
  // Capture pre-state: if a fade was in flight we already fired
1204
1424
  // `engineCrossfadeDidBegin` for `currentIndex + 1` — the
1205
1425
  // delegate believes the incoming is active. We must fire
1206
- // `engineCrossfadeDidCancel(revertedIndex: currentIndex)` so
1207
- // the delegate reverts active-track state back to the leading
1426
+ // `engineCrossfadeDidCancel` so the delegate reverts
1427
+ // active-track state back to the leading
1208
1428
  // leg. `emitRevert: false` is for callers that will immediately
1209
1429
  // set a new active track themselves (e.g. seek to a different
1210
1430
  // index) — emitting the intermediate revert would surface a
1211
1431
  // spurious track-change to the outgoing leg before the caller's
1212
1432
  // own track-change.
1213
1433
  let wasMidFade = fadeInFlight
1214
- let leadingIdx = currentIndex
1434
+ let wasArmed = standbyArmed
1435
+ let hadScheduledRamp = rampsScheduled
1215
1436
  fadeInFlight = false
1216
1437
  standbyArmed = false
1217
- // Backward seek through a scheduled ramp window leaves the
1218
- // mix in a wonky state per Wade Tregaskis strip the mix
1219
- // and re-apply the EQ-only mix to recover.
1220
- if let leading = leadingPlayer.currentItem {
1221
- leading.audioMix = nil
1222
- applyMixIfNeeded(to: leading)
1223
- }
1224
- if let standby = standbyPlayer.currentItem {
1225
- standby.audioMix = nil
1438
+ rampsScheduled = false
1439
+ // Workaround: a backward seek through a scheduled ramp window
1440
+ // leaves the mix in place with the wrong volume strip the mix
1441
+ // and re-apply the EQ-only mix to recover. See the type doc for
1442
+ // the source.
1443
+ //
1444
+ // Only when a ramp was scheduled: this runs on the item that is already
1445
+ // rendering, and re-applying builds a fresh processing tap that AVPlayer
1446
+ // silently drops on an item already in flight. Callers that cancel
1447
+ // routinely — every `remove` in a rebuild's removal loop — would otherwise
1448
+ // pay one dropped tap cascade per item removed.
1449
+ if hadScheduledRamp {
1450
+ if let leading = leadingPlayer.currentItem {
1451
+ leading.audioMix = nil
1452
+ applyMixIfNeeded(to: leading)
1453
+ }
1454
+ if let standby = standbyPlayer.currentItem {
1455
+ standby.audioMix = nil
1456
+ }
1226
1457
  }
1227
1458
  leadingPlayer.volume = userVolume
1228
1459
  standbyPlayer.volume = 0
1460
+ // `runFade` gave this leg a rate. Dropping its item does not stop it, so
1461
+ // without the pause the next arm attaches to a leg already advancing: its
1462
+ // mix is set on an item that has begun rendering and is silently dropped,
1463
+ // and the fade's ramp, anchored at asset-time zero, is already in the past.
1464
+ standbyPlayer.pause()
1465
+ // The arm turns this off as a documented precondition of `preroll` and
1466
+ // restores it once prerolled. A cancel in between skips that restore, and
1467
+ // the value survives the role flip that makes this leg the leading one —
1468
+ // where it means playback resumes into a stall instead of waiting.
1469
+ standbyPlayer.automaticallyWaitsToMinimizeStalling = true
1229
1470
  standbyPlayer.replaceCurrentItem(with: nil)
1230
1471
  if wasMidFade && emitRevert {
1231
- delegate?.engineCrossfadeDidCancel(self, revertedIndex: leadingIdx)
1472
+ delegate?.engineCrossfadeDidCancel(self)
1473
+ }
1474
+ // Every end of a fade, not just a reverted one. A cancel taken during the
1475
+ // arm window emits no revert — there is no track change to undo — but work
1476
+ // held back for the fade still has to be released, and pause, stop, the
1477
+ // arm deadline, a standby that fails to load and a route change all end
1478
+ // here without ever reaching `wasMidFade`.
1479
+ if wasMidFade || hadScheduledRamp || wasArmed {
1480
+ delegate?.engineFadeDidSettle(self)
1232
1481
  }
1233
1482
  }
1234
1483
 
1235
1484
  // MARK: - Engine event observer install/teardown
1236
1485
 
1237
1486
  /// Install KVO on the leading leg's `timeControlStatus` (drives
1238
- /// `engineStateMaybeChanged` + `engineIsPlayingChanged`) and on
1239
- /// the leading leg's currentItem `tracks` / `status` (drives
1240
- /// format-ready + failed-to-load delegate fires). Called whenever
1487
+ /// `engineStateMaybeChanged`) and on the leading leg's currentItem
1488
+ /// `tracks` / `status` (drives format-ready + failed-to-load
1489
+ /// delegate fires). Called whenever
1241
1490
  /// the leading leg or its currentItem changes — i.e. from
1242
1491
  /// [setItems], [seek], and [completeCrossfade] after the role
1243
1492
  /// flip. Idempotent — teardown runs first inside.
@@ -1246,15 +1495,34 @@ final class CrossfadeEngine: PlaybackEngine {
1246
1495
  let player = leadingPlayer
1247
1496
  leadingTimeControlObserver = player.observe(
1248
1497
  \.timeControlStatus, options: [.new]
1249
- ) { [weak self] obs, _ in
1250
- let isPlayingNow = obs.timeControlStatus == .playing
1251
- DispatchQueue.main.async {
1252
- guard let self else { return }
1498
+ ) { [weak self] _, _ in
1499
+ guard let self else { return }
1500
+ self.ownerQueue.async {
1253
1501
  self.delegate?.engineStateMaybeChanged(self, reason: nil)
1254
- self.delegate?.engineIsPlayingChanged(self, isPlaying: isPlayingNow)
1255
1502
  }
1256
1503
  }
1257
1504
  if let item = player.currentItem {
1505
+ // A local file that does not exist is deterministically unplayable, and
1506
+ // its `AVURLAsset` load can stall in `.unknown` indefinitely on `AVPlayer`
1507
+ // under host load — the item never reaches `.failed`, so a status observer
1508
+ // would never surface it and the leg sits buffering with no error. Fail it
1509
+ // now, and install no status observer for this item: a load that does
1510
+ // eventually reach `.failed` would otherwise re-report the same dead
1511
+ // source through the observer below, under a second error code that is
1512
+ // fatal where the missing-file code is transient — the retry path. A retry
1513
+ // re-seats the item and re-runs this check, so a source that appears is
1514
+ // picked up then. Read the asset's own URL, the one the player will load.
1515
+ if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
1516
+ !FileManager.default.fileExists(atPath: asset.url.path) {
1517
+ let missing = NSError(
1518
+ domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist,
1519
+ userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."])
1520
+ self.ownerQueue.async { [weak self] in
1521
+ guard let self else { return }
1522
+ self.delegate?.engineDidFailWithError(self, error: missing, item: item)
1523
+ }
1524
+ return
1525
+ }
1258
1526
  leadingItemTracksObserver = item.observe(
1259
1527
  \.tracks, options: [.new]
1260
1528
  ) { [weak self] obs, _ in
@@ -1263,8 +1531,8 @@ final class CrossfadeEngine: PlaybackEngine {
1263
1531
  // tracks array is populated so the gapless KVO path's
1264
1532
  // dedup contract is matched.
1265
1533
  guard !obs.tracks.isEmpty else { return }
1266
- DispatchQueue.main.async {
1267
- guard let self else { return }
1534
+ guard let self else { return }
1535
+ self.ownerQueue.async {
1268
1536
  self.delegate?.engineActiveItemFormatChanged(self)
1269
1537
  }
1270
1538
  }
@@ -1274,21 +1542,122 @@ final class CrossfadeEngine: PlaybackEngine {
1274
1542
  // propagates and the now-playing format stays nil. Mirrors what
1275
1543
  // `.initial` would have delivered.
1276
1544
  if !item.tracks.isEmpty {
1277
- DispatchQueue.main.async { [weak self] in
1545
+ self.ownerQueue.async { [weak self] in
1278
1546
  guard let self else { return }
1279
1547
  self.delegate?.engineActiveItemFormatChanged(self)
1280
1548
  }
1281
1549
  }
1550
+ // Leading-leg initial-buffer leniency, mirroring the gapless engine's
1551
+ // `gaplessFlipArmed`. A freshly seated item waits to minimize stalling
1552
+ // until it is `.readyToPlay`, then plays without further waiting. Without
1553
+ // the flip the leading leg keeps `automaticallyWaitsToMinimizeStalling`
1554
+ // set after a resume / engine swap and can take longer than a caller's
1555
+ // settle window to reach `.playing`. Re-arm to `true` for a not-yet-ready
1556
+ // item so a play-before-buffered waits rather than stalling to paused;
1557
+ // flip to `false` once the item is ready so a resume starts promptly.
1558
+ // `player` is captured so the leg that owns this item is the one flipped
1559
+ // even if the leading role has since swapped.
1560
+ player.automaticallyWaitsToMinimizeStalling = item.status != .readyToPlay
1282
1561
  leadingItemStatusObserver = item.observe(
1283
1562
  \.status, options: [.new]
1284
1563
  ) { [weak self] obs, _ in
1564
+ if obs.status == .readyToPlay {
1565
+ // Flip on `ownerQueue` like every other mutation here (and the gapless
1566
+ // flip in `dispatchItemStatus`), re-checking the item is still the
1567
+ // leg's current one. KVO does not abort an in-flight callback on
1568
+ // `invalidate()`, so a stale `.readyToPlay` for a since-replaced item
1569
+ // must not strand `autoWaits = false` on a fresh not-yet-ready item —
1570
+ // the very stall the re-arm above prevents.
1571
+ self?.ownerQueue.async {
1572
+ guard let self, player.currentItem === obs else { return }
1573
+ if self.mixProvider?.anyConsumerWantsTap() == true {
1574
+ // A tap is wanted. Install this item's audio tap BEFORE
1575
+ // permitting prompt render: AVPlayer honours `audioMix` only
1576
+ // before the item starts rendering (Apple contract, the same one
1577
+ // `GaplessEngine.setItems` installs before enqueue for) — a mix
1578
+ // set once the leg is already playing is silently dropped, its
1579
+ // `MTAudioProcessingTap` never prepares, and the equaliser and
1580
+ // visualizer tap lose their audio. `.readyToPlay` does not
1581
+ // guarantee the asset's `tracks` key is loaded and
1582
+ // `audioMix(for:)` yields nil until it is, so load tracks first
1583
+ // (as the arm path does for the standby), then install the mix
1584
+ // and resume any leg held at rate 0 for the tap. The leg stays
1585
+ // held (`autoWaits` true) until that completes; a consumer that
1586
+ // subscribes mid-playback then joins the already-running tap
1587
+ // instead of needing an unreliable mid-render re-attach.
1588
+ self.installLeadingTapMixThenResume(item: obs)
1589
+ } else {
1590
+ // No tap wanted: nothing to install; permit prompt render, and
1591
+ // resume a leg that was held for a tap since dropped (a consumer
1592
+ // unsubscribed before this fired) — leaving it at rate 0 would
1593
+ // strand it silent. A genuine pause has cleared `wantsPlayback`
1594
+ // (via `stopArmingObserver`), so this does not override it.
1595
+ player.automaticallyWaitsToMinimizeStalling = false
1596
+ if self.wantsPlayback, player.rate == 0 {
1597
+ player.rate = self.userPlaybackSpeed
1598
+ }
1599
+ }
1600
+ }
1601
+ return
1602
+ }
1285
1603
  guard obs.status == .failed else { return }
1286
1604
  let underlying = obs.error ?? NSError(
1287
1605
  domain: "CrossfadeEngine", code: -1, userInfo: nil
1288
1606
  )
1289
- DispatchQueue.main.async {
1607
+ guard let self else { return }
1608
+ // `obs` is the leading leg's current item — the one that failed.
1609
+ self.ownerQueue.async {
1610
+ self.delegate?.engineDidFailWithError(self, error: underlying, item: obs)
1611
+ }
1612
+ }
1613
+ // Bootstrap: `.new`-only KVO won't fire when the item is ALREADY `.failed`
1614
+ // at install time — a source that reached `.failed` between `setItems` and
1615
+ // the reinstall a `seek` runs. The reinstalled observer never sees the
1616
+ // transition, so surface it here, as `.initial` and the tracks observer
1617
+ // above both do.
1618
+ if item.status == .failed {
1619
+ let underlying = item.error ?? NSError(
1620
+ domain: "CrossfadeEngine", code: -1, userInfo: nil
1621
+ )
1622
+ self.ownerQueue.async { [weak self] in
1623
+ guard let self else { return }
1624
+ self.delegate?.engineDidFailWithError(self, error: underlying, item: item)
1625
+ }
1626
+ }
1627
+ // Buffer-flag KVO so a discrete buffer-state change reaches
1628
+ // `onBufferStateChange` on the crossfade engine, the same way the gapless
1629
+ // `AVQueuePlayer`'s item KVO delivers it. Without it the buffer surface
1630
+ // recomputes only on transport calls and the 500ms progress tick, so a
1631
+ // fast local reload's empty→full edge lands between samples and no change
1632
+ // is emitted. Routed to a delegate that recomputes ONLY the buffer surface
1633
+ // — no direct player-state emission; folding buffer edges into the state
1634
+ // path would churn `PlayerState` on every load edge.
1635
+ let onBuffer: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
1636
+ guard let self else { return }
1637
+ self.ownerQueue.async { [weak self] in
1638
+ guard let self else { return }
1639
+ self.delegate?.engineBufferStateMaybeChanged(self)
1640
+ }
1641
+ }
1642
+ leadingItemBufferEmptyObserver = item.observe(
1643
+ \.isPlaybackBufferEmpty, options: [.new], changeHandler: onBuffer)
1644
+ leadingItemBufferKeepUpObserver = item.observe(
1645
+ \.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onBuffer)
1646
+ leadingItemBufferFullObserver = item.observe(
1647
+ \.isPlaybackBufferFull, options: [.new], changeHandler: onBuffer)
1648
+ // Bootstrap: `.new`-only KVO won't fire for an item ALREADY buffer-ready
1649
+ // at install time — a cached / preloaded skip. Deliver the current reading
1650
+ // once, as the tracks / status bootstraps above do. Guarded on the ready
1651
+ // flags for the same reason those are guarded on `!tracks.isEmpty` /
1652
+ // `.failed`: bootstrap only when there is a settled state to deliver. An
1653
+ // unconditional fire queues a recompute on the owner queue on EVERY
1654
+ // seat / seek / advance — most of which seat a not-yet-buffered item that
1655
+ // the KVO will report when it settles — and that per-install churn shifts
1656
+ // the crossfade arm / preroll interleave on the serial queue under load.
1657
+ if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
1658
+ self.ownerQueue.async { [weak self] in
1290
1659
  guard let self else { return }
1291
- self.delegate?.engineDidFailWithError(self, error: underlying)
1660
+ self.delegate?.engineBufferStateMaybeChanged(self)
1292
1661
  }
1293
1662
  }
1294
1663
  }
@@ -1301,6 +1670,12 @@ final class CrossfadeEngine: PlaybackEngine {
1301
1670
  leadingItemTracksObserver = nil
1302
1671
  leadingItemStatusObserver?.invalidate()
1303
1672
  leadingItemStatusObserver = nil
1673
+ leadingItemBufferEmptyObserver?.invalidate()
1674
+ leadingItemBufferEmptyObserver = nil
1675
+ leadingItemBufferKeepUpObserver?.invalidate()
1676
+ leadingItemBufferKeepUpObserver = nil
1677
+ leadingItemBufferFullObserver?.invalidate()
1678
+ leadingItemBufferFullObserver = nil
1304
1679
  }
1305
1680
 
1306
1681
  private func handleItemDidPlayToEnd(_ note: Notification) {
@@ -1314,7 +1689,12 @@ final class CrossfadeEngine: PlaybackEngine {
1314
1689
  // every track (incl. the last), so it must precede the queue-end check.
1315
1690
  if repeatMode == .track, !fadeInFlight, currentIndex >= 0 {
1316
1691
  leadingPlayer.seek(to: .zero)
1317
- leadingPlayer.play()
1692
+ // `play()` resets the rate to 1.0, dropping the user's speed on every
1693
+ // loop; and the notification hops onto the owner queue, so a pause taken
1694
+ // in between must not be undone here.
1695
+ if wantsPlayback {
1696
+ leadingPlayer.rate = userPlaybackSpeed
1697
+ }
1318
1698
  return
1319
1699
  }
1320
1700
  // Queue-end detection: leading played its last item to end
@@ -1328,7 +1708,60 @@ final class CrossfadeEngine: PlaybackEngine {
1328
1708
  currentIndex >= 0,
1329
1709
  currentIndex >= queue.count - 1 {
1330
1710
  delegate?.enginePlaybackEnded(self)
1711
+ return
1712
+ }
1713
+ // The item ended mid-run with no fade to carry the boundary. Advancing
1714
+ // is this engine's job either way: a fade is how it usually crosses, not
1715
+ // the only way it can.
1716
+ //
1717
+ // Reachable whenever the arm did not happen or did not survive — the
1718
+ // standby failed to load, the arm chain timed out, a route change or a
1719
+ // queue mutation cancelled it, or the item was the last one installed and
1720
+ // there was nothing to preroll. Without this the queue simply stops on a
1721
+ // track that has finished, and nothing arrives to restart it.
1722
+ if !fadeInFlight, currentIndex >= 0, currentIndex < queue.count - 1 {
1723
+ advanceWithoutFade()
1724
+ }
1725
+ }
1726
+
1727
+ /// Attach the next queue item to the leading leg and advance the index, for
1728
+ /// an item that reached its end with no fade to carry the boundary.
1729
+ ///
1730
+ /// Follows `seek(toIndex:)`: capture playback intent, tear the fade state
1731
+ /// down through the one path that owns it, rebuild the incoming item, and
1732
+ /// resume only if the engine was playing.
1733
+ private func advanceWithoutFade() {
1734
+ if released { return }
1735
+ // The end-of-item notification hops onto the owner queue, so a pause taken
1736
+ // in between must not be undone by the rate write below.
1737
+ let shouldResume = wantsPlayback
1738
+ // Never by hand: the arm leaves a KVO observation, two timers and a task
1739
+ // behind, and the standby leg holding a buffering item. Suppress the
1740
+ // revert emit — the transition fired at the end of this method is the
1741
+ // authoritative track change.
1742
+ cancelFadeInternal(emitRevert: false)
1743
+ // `engineFadeDidSettle` above releases work deferred for the fade, which
1744
+ // can reconcile the run. Re-read the bounds it may have moved.
1745
+ guard currentIndex >= 0, currentIndex + 1 < queue.count else { return }
1746
+ teardownLeadingObservers()
1747
+ let nextIndex = currentIndex + 1
1748
+ // Fresh instance, never the stored one: the queue slot may still hold the
1749
+ // item the arm attached to the standby leg, and attaching an item that is
1750
+ // still associated with another player raises an uncatchable exception.
1751
+ let next = rebuiltItem(at: nextIndex)
1752
+ // Both before the attach: AVPlayer drops an `audioMix` set on an item that
1753
+ // has begun rendering, leaking the processing tap it carries.
1754
+ applyMixIfNeeded(to: next)
1755
+ applyPitchAlgorithm(to: next)
1756
+ leadingPlayer.replaceCurrentItem(with: next)
1757
+ currentIndex = nextIndex
1758
+ if shouldResume {
1759
+ // `replaceCurrentItem` leaves the player at rate 0 — resume respecting a
1760
+ // wanted tap, as `seek(toIndex:)` does.
1761
+ resumeLeadingRespectingTap()
1331
1762
  }
1763
+ installLeadingObservers()
1764
+ delegate?.engineDidTransitionTrack(self)
1332
1765
  }
1333
1766
 
1334
1767
  private func handleItemFailedToPlayToEnd(_ note: Notification) {
@@ -1337,6 +1770,7 @@ final class CrossfadeEngine: PlaybackEngine {
1337
1770
  let underlying = (note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error)
1338
1771
  ?? item.error
1339
1772
  ?? NSError(domain: "CrossfadeEngine", code: -2, userInfo: nil)
1340
- delegate?.engineDidFailWithError(self, error: underlying)
1773
+ // Guarded above as the leading leg's current item.
1774
+ delegate?.engineDidFailWithError(self, error: underlying, item: item)
1341
1775
  }
1342
1776
  }