react-native-queue-player 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +111 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +78 -30
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +13 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +985 -276
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  61. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +84 -18
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +122 -38
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +291 -27
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  114. package/app.plugin.js +17 -1
  115. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  116. package/ios/AVQueueBuilder.swift +120 -85
  117. package/ios/ArtworkLoader.swift +0 -7
  118. package/ios/ArtworkResolver.swift +22 -4
  119. package/ios/AssetReplayGainReader.swift +118 -0
  120. package/ios/AudioSession.swift +67 -16
  121. package/ios/AudioTapProvider.swift +284 -149
  122. package/ios/CarPlayBridge.swift +0 -12
  123. package/ios/CarPlayCoordinator.swift +18 -2
  124. package/ios/CarPlaySceneDelegate.swift +2 -2
  125. package/ios/Cast/AirPlayRouteState.swift +9 -8
  126. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  127. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  128. package/ios/Cast/Core/CastSession.swift +2 -2
  129. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  130. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  131. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  132. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  133. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  134. package/ios/CastManager.swift +1 -1
  135. package/ios/CrossfadeEngine.swift +672 -226
  136. package/ios/EQTap.swift +20 -17
  137. package/ios/Equalizer.swift +1 -1
  138. package/ios/FLACStreamInfo.swift +65 -0
  139. package/ios/GaplessEngine.swift +57 -30
  140. package/ios/InputGuards.swift +35 -5
  141. package/ios/LookaheadCache.swift +229 -58
  142. package/ios/LookaheadCachePrefetcher.swift +48 -36
  143. package/ios/MediaResponseCheck.swift +122 -0
  144. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  145. package/ios/NetworkRecoveryPolicy.swift +32 -0
  146. package/ios/NowPlayingFormatExtractor.swift +25 -18
  147. package/ios/NowPlayingInfo.swift +84 -48
  148. package/ios/OutputRouteMonitor.swift +16 -3
  149. package/ios/PendingBrowseRequests.swift +1 -1
  150. package/ios/PlaceholderArtwork.swift +9 -5
  151. package/ios/PlaybackEngine.swift +165 -76
  152. package/ios/PlaybackErrorMapping.swift +3 -3
  153. package/ios/PlaybackModeStateMachine.swift +2 -2
  154. package/ios/PlaybackNetworkMonitor.swift +73 -0
  155. package/ios/PlayerStateDerivation.swift +6 -6
  156. package/ios/QueueMutationArithmetic.swift +25 -0
  157. package/ios/QueueSkipArithmetic.swift +5 -3
  158. package/ios/QueueWindowArithmetic.swift +152 -0
  159. package/ios/ReadThroughServer.swift +283 -0
  160. package/ios/RemoteCommands.swift +44 -4
  161. package/ios/ReplayGainData.swift +22 -3
  162. package/ios/ReplayGainExtractor.swift +70 -27
  163. package/ios/Siri/VoiceDonation.swift +14 -9
  164. package/ios/StreamingBitrateProbe.swift +22 -68
  165. package/ios/SuppliedReplayGain.swift +83 -0
  166. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  167. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  168. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  169. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  170. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  171. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  172. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  173. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  174. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  175. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  176. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  177. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  178. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  179. package/ios/Tests/EngineSwapTests.swift +91 -0
  180. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  181. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  182. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  183. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  184. package/ios/Tests/InputGuardsTests.swift +41 -0
  185. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  186. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  187. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  188. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  189. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  190. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  191. package/ios/Tests/MediaResponseCheckTests.swift +128 -0
  192. package/ios/Tests/MutationDeferralTests.swift +220 -0
  193. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  194. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  195. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  196. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  197. package/ios/Tests/PlayerFixtures.swift +48 -0
  198. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  199. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  200. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  201. package/ios/Tests/QueueStateTests.swift +3 -1
  202. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  203. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  204. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  205. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  206. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  207. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  208. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  209. package/ios/Tests/RetryRecoveryTests.swift +418 -0
  210. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  211. package/ios/Tests/SkipIndexTests.swift +32 -2
  212. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  213. package/ios/Tests/StallRecoveryTests.swift +97 -0
  214. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  215. package/ios/Tests/TopUpWindowGateTests.swift +441 -0
  216. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  217. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  218. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  219. package/ios/Tests/TrackPlayerEndVerdictTests.swift +206 -0
  220. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  221. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  222. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  223. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  224. package/ios/TrackPlayer+Automotive.swift +101 -0
  225. package/ios/TrackPlayer+Cache.swift +232 -0
  226. package/ios/TrackPlayer+Config.swift +291 -0
  227. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  228. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  229. package/ios/TrackPlayer+EventsDispatch.swift +1042 -0
  230. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  231. package/ios/TrackPlayer+Lifecycle.swift +982 -0
  232. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  233. package/ios/TrackPlayer+Queue.swift +951 -0
  234. package/ios/TrackPlayer+Recovery.swift +213 -0
  235. package/ios/TrackPlayer+Skip.swift +465 -0
  236. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  237. package/ios/TrackPlayer+State.swift +108 -0
  238. package/ios/TrackPlayer+Threading.swift +135 -0
  239. package/ios/TrackPlayer+Transport.swift +272 -0
  240. package/ios/TrackPlayer+Window.swift +227 -0
  241. package/ios/TrackPlayer.swift +331 -4619
  242. package/ios/Visualizer.swift +6 -5
  243. package/ios/tests-harness/Podfile +1 -1
  244. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  245. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  246. package/lib/module/hooks/useActiveTrack.js +29 -22
  247. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  248. package/lib/module/hooks/useCast.js +8 -26
  249. package/lib/module/hooks/useCast.js.map +1 -1
  250. package/lib/module/hooks/useEqualizer.js +19 -12
  251. package/lib/module/hooks/useEqualizer.js.map +1 -1
  252. package/lib/module/hooks/useLookaheadCache.js +3 -7
  253. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  254. package/lib/module/hooks/useQueue.js +66 -19
  255. package/lib/module/hooks/useQueue.js.map +1 -1
  256. package/lib/module/index.js +6 -3
  257. package/lib/module/index.js.map +1 -1
  258. package/lib/module/queueDelta.js +41 -0
  259. package/lib/module/queueDelta.js.map +1 -0
  260. package/lib/module/types.js +28 -5
  261. package/lib/module/types.js.map +1 -1
  262. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  263. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  265. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  266. package/lib/typescript/hooks/useCast.d.ts +6 -1
  267. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  269. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  271. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  272. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  273. package/lib/typescript/index.d.ts +2 -1
  274. package/lib/typescript/index.d.ts.map +1 -1
  275. package/lib/typescript/queueDelta.d.ts +10 -0
  276. package/lib/typescript/queueDelta.d.ts.map +1 -0
  277. package/lib/typescript/types.d.ts +168 -12
  278. package/lib/typescript/types.d.ts.map +1 -1
  279. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  280. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  281. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  282. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  283. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  284. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  285. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  320. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  321. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  322. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  323. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  324. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  325. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  326. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  327. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  328. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  329. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  330. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  331. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  332. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  333. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  334. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  335. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  336. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  337. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  338. package/package.json +8 -8
  339. package/src/TrackPlayer.nitro.ts +53 -27
  340. package/src/hooks/useActiveTrack.ts +29 -22
  341. package/src/hooks/useCast.ts +14 -15
  342. package/src/hooks/useEqualizer.ts +19 -12
  343. package/src/hooks/useLookaheadCache.ts +3 -7
  344. package/src/hooks/useQueue.ts +66 -17
  345. package/src/index.ts +13 -3
  346. package/src/queueDelta.ts +41 -0
  347. package/src/types.ts +169 -12
  348. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  349. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  350. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  351. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  352. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  353. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  354. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  355. package/ios/MetadataReader.swift +0 -555
  356. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  357. package/ios/Tests/MetadataReaderTests.swift +0 -603
  358. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  359. package/ios/Tests/VoiceDonationTests.swift +0 -26
  360. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  361. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  362. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -91,8 +91,8 @@ final class SkipIndexTests: XCTestCase {
91
91
 
92
92
  func testComputePreviousRepeatOffAtStartReturnsNil() {
93
93
  // No rewind-on-boundary under .off. The consumer-facing "tap Previous
94
- // at index 0 restarts the track" behaviour lives in the skipToPrevious
95
- // glue, not here.
94
+ // at index 0 restarts the track" behaviour lives in `skipToPrevious`,
95
+ // not here.
96
96
  XCTAssertNil(QueueSkipArithmetic.computePrevious(
97
97
  trackCount: 3, currentIndex: 0, repeatMode: .off))
98
98
  }
@@ -353,4 +353,34 @@ final class SkipIndexTests: XCTestCase {
353
353
  XCTAssertTrue(QueueSkipArithmetic.canSkipPrevious(
354
354
  trackCount: 3, currentIndex: 1, repeatMode: .off, restartEnabled: false))
355
355
  }
356
+
357
+ func testCanSkipPreviousRestartNothingPlayingReturnsFalseInAnyMode() {
358
+ // Nothing is loaded, so there is no track to restart and no previous
359
+ // track to reach: resolveSkipToPrevious returns no-op for this state.
360
+ for mode in [QueueSkipRepeatMode.off, .queue, .track] {
361
+ XCTAssertFalse(QueueSkipArithmetic.canSkipPrevious(
362
+ trackCount: 5, currentIndex: -1, repeatMode: mode, restartEnabled: true))
363
+ }
364
+ }
365
+
366
+ func testCanSkipPreviousAgreesWithResolveSkipToPrevious() {
367
+ // canSkipPrevious is the enabled-state of the same decision
368
+ // resolveSkipToPrevious makes; the two must never disagree.
369
+ for count in [0, 1, 3] {
370
+ for index in [-1, 0, 1, 2] {
371
+ for mode in [QueueSkipRepeatMode.off, .queue, .track] {
372
+ for restart in [false, true] {
373
+ let can = QueueSkipArithmetic.canSkipPrevious(
374
+ trackCount: count, currentIndex: index, repeatMode: mode,
375
+ restartEnabled: restart)
376
+ let resolved = QueueSkipArithmetic.resolveSkipToPrevious(
377
+ trackCount: count, currentIndex: index, repeatMode: mode,
378
+ restartEnabled: restart, positionMs: 0)
379
+ XCTAssertEqual(can, resolved != .noOp,
380
+ "count=\(count) index=\(index) mode=\(mode) restart=\(restart)")
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
356
386
  }
@@ -0,0 +1,43 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// The sleep timer's pause is a transport intent: the paths that restart
5
+ /// playback on their own (a drain refill, a buffer coming back) read
6
+ /// `wantsToPlay`, so the timer clears it the way a user pause does.
7
+ final class SleepTimerPauseIntentTests: XCTestCase {
8
+
9
+ private var player: TrackPlayer!
10
+
11
+ override func setUp() async throws {
12
+ try await super.setUp()
13
+ player = TrackPlayer()
14
+ _ = try await player.configure(config: PlayerFixtures.config()).await()
15
+ _ = try await player.setQueue(
16
+ tracks: PlayerFixtures.localTracks(count: 2, for: self), startAtIndex: 0
17
+ ).await()
18
+ }
19
+
20
+ override func tearDown() async throws {
21
+ if let player {
22
+ _ = try await player.destroy().await()
23
+ }
24
+ player = nil
25
+ try await super.tearDown()
26
+ }
27
+
28
+ func testTheTimersPauseClearsTheIntentToPlay() async throws {
29
+ let player = try XCTUnwrap(self.player)
30
+ await player.onPlayerQueue {
31
+ player.wantsToPlay = true
32
+ player.isRecoveringFromStall = true
33
+ player.applySleepTimerResult(
34
+ SleepTimerCore.Tick(fadeFraction: 1.0, pauseNow: true, stateChanged: true))
35
+ }
36
+
37
+ let state = await player.onPlayerQueueValue {
38
+ (wants: player.wantsToPlay, recovering: player.isRecoveringFromStall)
39
+ }
40
+ XCTAssertFalse(state.wants, "a drain or a buffer refill must not restart what the timer stopped")
41
+ XCTAssertFalse(state.recovering)
42
+ }
43
+ }
@@ -0,0 +1,97 @@
1
+ import AVFoundation
2
+ import XCTest
3
+ @testable import QueuePlayer
4
+
5
+ /// Playback runs with `automaticallyWaitsToMinimizeStalling = false`, so
6
+ /// AVPlayer does not resume itself after a buffer underrun. A stall arms a
7
+ /// recovery that re-issues play when the buffer refills, and transport intent
8
+ /// decides whether it arms at all.
9
+ ///
10
+ /// The resume itself needs a real item whose buffer actually refills, so that
11
+ /// half is covered by the streaming demo suites; what is covered here is the
12
+ /// arming and clearing, which is where the intent bugs would be.
13
+ final class StallRecoveryTests: XCTestCase {
14
+
15
+ func testAStallArmsRecoveryWhilePlaybackIsIntended() {
16
+ let player = TrackPlayer()
17
+ let active = seedActiveItem(player)
18
+ player.wantsToPlay = true
19
+
20
+ player.handlePlaybackStalled(
21
+ Notification(name: AVPlayerItem.playbackStalledNotification, object: active))
22
+ drainPlayerQueue(player)
23
+
24
+ XCTAssertTrue(player.isRecoveringFromStall)
25
+ }
26
+
27
+ /// The observer sees every item in the process; a stall on an item that
28
+ /// is not the one being heard is not this player's stall.
29
+ func testAStallOnAnotherItemDoesNotArmRecovery() {
30
+ let player = TrackPlayer()
31
+ _ = seedActiveItem(player)
32
+ player.wantsToPlay = true
33
+ let other = AVPlayerItem(url: URL(fileURLWithPath: "/dev/null/other.mp3"))
34
+
35
+ player.handlePlaybackStalled(
36
+ Notification(name: AVPlayerItem.playbackStalledNotification, object: other))
37
+ drainPlayerQueue(player)
38
+
39
+ XCTAssertFalse(player.isRecoveringFromStall)
40
+ }
41
+
42
+ /// A notification with no item names nothing this player is playing — also
43
+ /// while the player itself has no active item, where "no item" and "no
44
+ /// active item" must not be read as a match.
45
+ func testAStallWithNoItemDoesNotArmRecovery() {
46
+ let player = TrackPlayer()
47
+ player.wantsToPlay = true
48
+
49
+ player.handlePlaybackStalled(Notification(name: AVPlayerItem.playbackStalledNotification))
50
+ drainPlayerQueue(player)
51
+
52
+ XCTAssertFalse(player.isRecoveringFromStall)
53
+ }
54
+
55
+ func testAStallWhilePausedDoesNotArmRecovery() {
56
+ let player = TrackPlayer()
57
+ let active = seedActiveItem(player)
58
+ player.wantsToPlay = false
59
+
60
+ player.handlePlaybackStalled(
61
+ Notification(name: AVPlayerItem.playbackStalledNotification, object: active))
62
+ drainPlayerQueue(player)
63
+
64
+ XCTAssertFalse(
65
+ player.isRecoveringFromStall,
66
+ "a stall arriving while paused must not arm a resume that fires later")
67
+ }
68
+
69
+ func testAnExplicitPauseDisarmsAPendingRecovery() async throws {
70
+ let player = TrackPlayer()
71
+ player.wantsToPlay = true
72
+ player.isRecoveringFromStall = true
73
+
74
+ _ = try await player.pause().await()
75
+
76
+ XCTAssertFalse(
77
+ player.isRecoveringFromStall,
78
+ "an explicit pause outranks a pending recovery, or the next refill "
79
+ + "restarts audio the user just stopped")
80
+ }
81
+
82
+ /// Installs an engine holding one item and returns that item.
83
+ private func seedActiveItem(_ player: TrackPlayer) -> AVPlayerItem {
84
+ let engine = ActiveItemEngineStub()
85
+ let item = AVPlayerItem(url: URL(fileURLWithPath: "/dev/null/active.mp3"))
86
+ engine.item = item
87
+ player.engine = engine
88
+ return item
89
+ }
90
+
91
+ /// The handler hops to the player queue, so the assertion has to follow it.
92
+ private func drainPlayerQueue(_ player: TrackPlayer) {
93
+ let drained = expectation(description: "player queue drained")
94
+ player.playerQueue.async { drained.fulfill() }
95
+ wait(for: [drained], timeout: 2.0)
96
+ }
97
+ }
@@ -1,29 +1,26 @@
1
1
  import XCTest
2
2
  @testable import QueuePlayer
3
3
 
4
- /// Pins `StreamingBitrateProbe`'s network-isolation seam: a remote probe
5
- /// must fetch via the injected `URLSession`, never the global
6
- /// `URLSession.shared`. The bitrate-parsing math is exercised end-to-end
7
- /// through the demo format/replaygain suites.
4
+ /// The probe derives a bitrate from a local file's first 256 KB and reads
5
+ /// nothing that is not a `file://` URL.
8
6
  final class StreamingBitrateProbeTests: XCTestCase {
9
7
 
10
- func testRemoteProbeUsesInjectedSession() async {
11
- let config = URLSessionConfiguration.ephemeral
12
- config.protocolClasses = [StubURLProtocol.self]
13
- let session = URLSession(configuration: config)
14
- StubURLProtocol.reset()
15
- defer { StubURLProtocol.reset() }
16
- // Unique URL per run so the probe's process-wide bitrate cache can't
17
- // satisfy the call without a fetch. `.test` is a reserved
18
- // non-resolvable TLD, so a real-network leak would fail with -1003
19
- // instead of reaching the stub.
20
- let url = URL(string: "https://example.test/probe-\(UUID().uuidString).m4a")!
21
- StubURLProtocol.stub(url: url, data: Data(count: 4096), statusCode: 200)
8
+ func testALocalCbrMp3ReportsItsEncodedBitrate() async throws {
9
+ let url = try PlayerFixtures.fixtureURL(name: "silence-cbr-128", ext: "mp3", for: self)
10
+ let bitrate = await StreamingBitrateProbe.bitrate(forUrl: url)
11
+ XCTAssertEqual(try XCTUnwrap(bitrate), 128_000, accuracy: 128_000 * 0.05)
12
+ }
22
13
 
23
- _ = await StreamingBitrateProbe.bitrate(forUrl: url, urlSession: session)
14
+ func testARemoteUrlIsNotProbed() async throws {
15
+ let url = try XCTUnwrap(URL(string: "http://127.0.0.1:1/\(UUID().uuidString).mp3"))
16
+ let bitrate = await StreamingBitrateProbe.bitrate(forUrl: url)
17
+ XCTAssertNil(bitrate)
18
+ }
24
19
 
25
- XCTAssertEqual(
26
- StubURLProtocol.requestCount(for: url), 1,
27
- "remote probe must fetch through the injected session, never URLSession.shared")
20
+ func testAMissingFileReportsNoBitrate() async {
21
+ let url = FileManager.default.temporaryDirectory
22
+ .appendingPathComponent("absent-\(UUID().uuidString).mp3")
23
+ let bitrate = await StreamingBitrateProbe.bitrate(forUrl: url)
24
+ XCTAssertNil(bitrate)
28
25
  }
29
26
  }
@@ -0,0 +1,441 @@
1
+ import AVFoundation
2
+ import XCTest
3
+ @testable import QueuePlayer
4
+ @_spi(QueuePlayerTests) import QueuePlayer
5
+
6
+ /// Extending the installed run waits for a crossfade rather than cutting it
7
+ /// short, and reads the run's start from what is actually being heard.
8
+ final class TopUpWindowGateTests: XCTestCase {
9
+
10
+ private var player: TrackPlayer!
11
+
12
+ override func setUp() async throws {
13
+ try await super.setUp()
14
+ player = TrackPlayer()
15
+ }
16
+
17
+ override func tearDown() async throws {
18
+ player = nil
19
+ try await super.tearDown()
20
+ }
21
+
22
+ /// The same run, the same owed top-up: only the pending fade differs.
23
+ func testAPendingFadeIsWhatHoldsTheTopUpBack() async throws {
24
+ let engine = WindowEngineStub(count: 3)
25
+ engine.fadePending = true
26
+ try await seed(engine: engine, trackCount: 20)
27
+
28
+ await onQueue { $0.topUpWindow() }
29
+ XCTAssertTrue(
30
+ engine.inserted.isEmpty,
31
+ "a pending fade must not be cut short to extend the run")
32
+
33
+ engine.fadePending = false
34
+ await onQueue { $0.topUpWindow() }
35
+ XCTAssertFalse(
36
+ engine.inserted.isEmpty,
37
+ "the same run extends once there is no fade to protect")
38
+ }
39
+
40
+ /// The run starts at the item being heard. An engine that retains played
41
+ /// items reports those first, and measuring from them would call the run
42
+ /// satisfied while the playhead sat near its end.
43
+ func testTheRunIsMeasuredFromTheItemBeingHeardNotTheFirstInstalled() async throws {
44
+ // Ten installed positions, but the playhead is at the last of them — so
45
+ // the run ahead is exhausted and a top-up is owed.
46
+ let engine = WindowEngineStub(count: 10)
47
+ engine.leadingIndex = 9
48
+ try await seed(engine: engine, trackCount: 30)
49
+
50
+ await onQueue { $0.topUpWindow() }
51
+
52
+ XCTAssertFalse(
53
+ engine.inserted.isEmpty,
54
+ "measured from the playhead the run is exhausted, so it must extend")
55
+ }
56
+
57
+ /// A skip is applied inside a mutation, and the transition it raises is not
58
+ /// allowed to top up while one is in flight — so the skip extends the run
59
+ /// itself when it lands on the last installed position.
60
+ func testASkipOntoTheLastInstalledPositionExtendsTheRun() async throws {
61
+ let engine = WindowEngineStub(count: 4)
62
+ try await seed(engine: engine, trackCount: 20)
63
+
64
+ await onQueue { $0.applyNewCurrentIndex(newIndex: 3, reason: .userSkipToIndex) }
65
+
66
+ XCTAssertFalse(
67
+ engine.inserted.isEmpty,
68
+ "the run ahead of the skip target is empty and must be extended")
69
+ }
70
+
71
+ /// The crossfade steady state: the whole queue installed, the playhead part
72
+ /// way into it. This is where the claim that the machinery is inert has to
73
+ /// hold, and a shorter-than-floor queue does not test it.
74
+ func testAWhollyInstalledQueueNeedsNoTopUpFromMidRun() async throws {
75
+ let engine = WindowEngineStub(count: 30)
76
+ engine.leadingIndex = 5
77
+ try await seed(engine: engine, trackCount: 30)
78
+
79
+ await onQueue { $0.topUpWindow() }
80
+
81
+ XCTAssertTrue(
82
+ engine.inserted.isEmpty,
83
+ "nothing is owed when the queue is already installed to its end")
84
+ }
85
+
86
+ /// The engine refusing the append falls back to installing the slice.
87
+ func testAnEngineThatRefusesTheAppendFallsBackToAFullInstall() async throws {
88
+ let engine = WindowEngineStub(count: 3)
89
+ engine.refuseInserts = true
90
+ try await seed(engine: engine, trackCount: 20)
91
+
92
+ await onQueue { $0.topUpWindow() }
93
+
94
+ XCTAssertTrue(engine.inserted.isEmpty)
95
+ XCTAssertTrue(
96
+ engine.didSetItems, "a refused append must be recovered by a full install")
97
+ }
98
+
99
+ func testAFullTailNeedsNoTopUp() async throws {
100
+ let engine = WindowEngineStub(count: 4)
101
+ try await seed(engine: engine, trackCount: 4)
102
+
103
+ await onQueue { $0.topUpWindow() }
104
+
105
+ XCTAssertTrue(
106
+ engine.inserted.isEmpty,
107
+ "the queue is wholly installed, so there is nothing to append")
108
+ }
109
+
110
+ /// A queue that has ended is not extended by a top-up, even while the
111
+ /// engine still holds a short run and the model has tracks past it: the
112
+ /// finished track would otherwise get a run appended behind it, and the
113
+ /// next play() would fade into it.
114
+ func testTopUpDoesNotExtendAnEndedQueue() async throws {
115
+ let engine = WindowEngineStub(count: 3)
116
+ engine.leadingIndex = 2
117
+ try await seed(engine: engine, trackCount: 20)
118
+ await onQueue { $0.reachedQueueEnd = true }
119
+
120
+ await onQueue { $0.topUpWindow() }
121
+
122
+ XCTAssertTrue(engine.inserted.isEmpty, "nothing is appended behind the finished track")
123
+ XCTAssertFalse(engine.didSetItems, "an ended queue must not be seated again by a top-up")
124
+ }
125
+
126
+ /// The same holds for a drained engine that has not ended: seating it is the
127
+ /// caller's job, not the top-up's.
128
+ func testTopUpDoesNotSeatADrainedEngine() async throws {
129
+ let engine = WindowEngineStub(count: 0)
130
+ try await seed(engine: engine, trackCount: 20)
131
+
132
+ await onQueue { $0.topUpWindow() }
133
+
134
+ XCTAssertFalse(engine.didSetItems)
135
+ }
136
+
137
+ /// An add that fits inside the window is inserted surgically, so the items
138
+ /// already buffering ahead of it are kept.
139
+ func testAnAddInsideTheWindowIsInsertedWithoutReinstalling() async throws {
140
+ let engine = WindowEngineStub(count: QueueWindowArithmetic.floor)
141
+ try await seed(engine: engine, trackCount: QueueWindowArithmetic.floor)
142
+
143
+ _ = try await player.addToQueue(
144
+ tracks: [
145
+ PlayerFixtures.track(id: "n0", url: "https://example.com/n0.m4a"),
146
+ PlayerFixtures.track(id: "n1", url: "https://example.com/n1.m4a"),
147
+ ],
148
+ insertBefore: 3
149
+ ).await()
150
+
151
+ XCTAssertEqual(engine.inserted.count, 2, "both added items are inserted")
152
+ XCTAssertFalse(engine.didSetItems, "nothing is reinstalled")
153
+ let ids = try await onQueueValue { $0.queueItemIds }
154
+ XCTAssertEqual(
155
+ engine.allMediaItems.compactMap(\.queueItemId), Array(ids.prefix(QueueWindowArithmetic.floor + 2)),
156
+ "the run is the queue's positions in order with the two added items in place")
157
+ }
158
+
159
+ /// An add that runs past the window is not built item by item: the engine
160
+ /// is reconciled to the slice, and `topUpWindow` materialises the rest as
161
+ /// the playhead advances.
162
+ func testAnAddPastTheWindowBuildsOnlyTheSlice() async throws {
163
+ let engine = WindowEngineStub(count: QueueWindowArithmetic.floor)
164
+ try await seed(engine: engine, trackCount: QueueWindowArithmetic.floor)
165
+ let album = (0 ..< 30).map {
166
+ PlayerFixtures.track(id: "n\($0)", url: "https://example.com/n\($0).m4a")
167
+ }
168
+
169
+ _ = try await player.addToQueue(tracks: album, insertBefore: 5).await()
170
+
171
+ XCTAssertEqual(
172
+ engine.inserted.count, QueueWindowArithmetic.floor - 5,
173
+ "only the positions inside the window are built")
174
+ XCTAssertFalse(engine.didSetItems, "the run is trimmed and extended, not reinstalled")
175
+ let ids = try await onQueueValue { $0.queueItemIds }
176
+ XCTAssertEqual(
177
+ engine.allMediaItems.compactMap(\.queueItemId), Array(ids.prefix(QueueWindowArithmetic.floor)),
178
+ "the engine holds exactly the window: the five kept positions and the first five added")
179
+ }
180
+
181
+ /// The same after the end of the queue on an engine that keeps its items:
182
+ /// the finished item is a materialised anchor, and an item inserted behind
183
+ /// it would be what the next play() fades into.
184
+ func testAnAppendAfterTheEndOfTheQueueLeavesARetainingEngineAlone() async throws {
185
+ let engine = WindowEngineStub(count: 3)
186
+ engine.leadingIndex = 2
187
+ try await seed(engine: engine, trackCount: 3)
188
+ await onQueue { $0.reachedQueueEnd = true }
189
+
190
+ _ = try await player.addToQueue(
191
+ tracks: [PlayerFixtures.track(id: "t3", url: "https://example.com/t3.m4a")],
192
+ insertBefore: nil
193
+ ).await()
194
+
195
+ XCTAssertTrue(engine.inserted.isEmpty, "nothing is inserted behind the finished item")
196
+ XCTAssertFalse(engine.didSetItems)
197
+ let state = try await onQueueValue { ($0.currentTrackIndex, $0.reachedQueueEnd) }
198
+ XCTAssertEqual(state.0, 2)
199
+ XCTAssertTrue(state.1)
200
+ }
201
+
202
+ /// A drain reported while the queue is already over does not re-decide it:
203
+ /// with tracks added behind the finished one, the verdict would otherwise be
204
+ /// `.windowDrained` and seat the added track.
205
+ func testARepeatedEndReportWhileEndedSeatsNothing() async throws {
206
+ let engine = WindowEngineStub(count: 0)
207
+ engine.leadingIndex = 2
208
+ try await seed(engine: engine, trackCount: 3)
209
+ await onQueue {
210
+ $0.reachedQueueEnd = true
211
+ _ = $0.queueState.insert(
212
+ [PlayerFixtures.track(id: "t3", url: "https://example.com/t3.m4a")], at: 3)
213
+ }
214
+
215
+ await onQueue { $0.applyEndVerdict() }
216
+
217
+ XCTAssertFalse(engine.didSetItems, "the ended queue is not re-seated")
218
+ let state = try await onQueueValue { ($0.currentTrackIndex, $0.reachedQueueEnd) }
219
+ XCTAssertEqual(state.0, 2, "the index stays on the track that finished")
220
+ XCTAssertTrue(state.1)
221
+ }
222
+
223
+ /// An append after the end of the queue extends the model, not the engine:
224
+ /// inserting into a drained engine would seat playback on the new track
225
+ /// while `currentTrackIndex` still names the one that finished, so the
226
+ /// next `play()` would start a track the consumer was never told about.
227
+ func testAnAppendAfterTheEndOfTheQueueLeavesTheDrainedEngineAlone() async throws {
228
+ let engine = WindowEngineStub(count: 0)
229
+ engine.leadingIndex = 2
230
+ try await seed(engine: engine, trackCount: 3)
231
+ await onQueue { $0.reachedQueueEnd = true }
232
+
233
+ _ = try await player.addToQueue(
234
+ tracks: [PlayerFixtures.track(id: "t3", url: "https://example.com/t3.m4a")],
235
+ insertBefore: nil
236
+ ).await()
237
+
238
+ XCTAssertTrue(engine.inserted.isEmpty, "nothing is inserted into a drained engine")
239
+ XCTAssertFalse(engine.didSetItems)
240
+ let state = try await onQueueValue {
241
+ ($0.tracks.count, $0.currentTrackIndex, $0.reachedQueueEnd)
242
+ }
243
+ XCTAssertEqual(state.0, 4, "the queue model carries the append")
244
+ XCTAssertEqual(state.1, 2, "the current index stays on the track that finished")
245
+ XCTAssertTrue(state.2, "the queue is still over until a skip or seek says otherwise")
246
+ }
247
+
248
+ /// Reinstalling the run the engine is already rendering from restarts the
249
+ /// track being heard unless its playhead is carried across.
250
+ func testAForcedReinstallKeepsThePlayheadOfTheTrackBeingHeard() async throws {
251
+ let engine = WindowEngineStub(count: 12)
252
+ engine.leadingIndex = 4
253
+ engine.position = 37.5
254
+ try await seed(engine: engine, trackCount: 20)
255
+
256
+ await onQueue { p in
257
+ p.performingMutation { p.fullRebuildPlayerQueue(forcingReinstall: true) }
258
+ }
259
+
260
+ XCTAssertTrue(engine.didSetItems, "the run is installed whole")
261
+ XCTAssertEqual(
262
+ engine.seatedAtPosition, 37.5,
263
+ "the track being heard resumes where it was, not from its start")
264
+ }
265
+
266
+ /// A reinstall that seats on a different track is a track change, and a
267
+ /// track change starts at the beginning.
268
+ func testAReinstallOntoADifferentTrackStartsItFromZero() async throws {
269
+ let engine = WindowEngineStub(count: 12)
270
+ engine.leadingIndex = 4
271
+ engine.position = 37.5
272
+ try await seed(engine: engine, trackCount: 20)
273
+ await onQueue { $0.currentTrackIndex = 9 }
274
+
275
+ await onQueue { p in
276
+ p.performingMutation { p.fullRebuildPlayerQueue(forcingReinstall: true) }
277
+ }
278
+
279
+ XCTAssertEqual(engine.seatedAtPosition, 0)
280
+ }
281
+
282
+ /// The same run, the same owed top-up: only the current track's reported
283
+ /// failure differs.
284
+ func testAReportedFailureOnTheCurrentTrackIsWhatHoldsTheTopUpBack() async throws {
285
+ let engine = WindowEngineStub(count: 3)
286
+ try await seed(engine: engine, trackCount: 20)
287
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[$0.currentTrackIndex]] }
288
+
289
+ await onQueue { $0.topUpWindow() }
290
+ XCTAssertTrue(
291
+ engine.inserted.isEmpty,
292
+ "the track whose failure was reported is not installed again")
293
+
294
+ await onQueue { $0.reportedFailureQueueItemIds.removeAll() }
295
+ await onQueue { $0.topUpWindow() }
296
+ XCTAssertFalse(
297
+ engine.inserted.isEmpty,
298
+ "the same run extends once the track is no longer the reported failure")
299
+ }
300
+
301
+ /// The same run, the same owed top-up: only the scheduled attempt differs.
302
+ func testAScheduledAttemptForTheCurrentTrackAlsoHoldsTheTopUpBack() async throws {
303
+ let engine = WindowEngineStub(count: 3)
304
+ try await seed(engine: engine, trackCount: 20)
305
+ await onQueue { $0.pendingRetryQueueItemId = $0.queueItemIds[$0.currentTrackIndex] }
306
+
307
+ await onQueue { $0.topUpWindow() }
308
+ XCTAssertTrue(
309
+ engine.inserted.isEmpty,
310
+ "the attempt re-seats the track itself; rebuilding first fails it again")
311
+
312
+ await onQueue { $0.pendingRetryQueueItemId = nil }
313
+ await onQueue { $0.topUpWindow() }
314
+ XCTAssertFalse(engine.inserted.isEmpty)
315
+ }
316
+
317
+ /// A failure against a track the playhead has left does not stop the run
318
+ /// being extended.
319
+ func testATopUpRunsWhenTheReportedFailureIsADifferentTrack() async throws {
320
+ let engine = WindowEngineStub(count: 3)
321
+ try await seed(engine: engine, trackCount: 20)
322
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[7]] }
323
+
324
+ await onQueue { $0.topUpWindow() }
325
+
326
+ XCTAssertFalse(engine.inserted.isEmpty)
327
+ }
328
+
329
+ // MARK: - Helpers
330
+
331
+ private func seed(engine: WindowEngineStub, trackCount: Int) async throws {
332
+ let player = try XCTUnwrap(self.player)
333
+ await player.onPlayerQueue {
334
+ let ids = player.queueState.replaceAll(
335
+ (0 ..< trackCount).map {
336
+ PlayerFixtures.track(id: "t\($0)", url: "https://example.com/\($0).m4a")
337
+ })
338
+ engine.stampIds(Array(ids.prefix(engine.installedCount)))
339
+ player.currentTrackIndex = engine.leadingIndex
340
+ player.engine = engine
341
+ }
342
+ }
343
+
344
+ private func onQueue(_ body: @escaping (TrackPlayer) -> Void) async {
345
+ guard let player else { return }
346
+ await player.onPlayerQueue { body(player) }
347
+ }
348
+
349
+ private func onQueueValue<T>(_ body: @escaping (TrackPlayer) -> T) async throws -> T {
350
+ let player = try XCTUnwrap(self.player)
351
+ return await player.onPlayerQueueValue { body(player) }
352
+ }
353
+ }
354
+
355
+ /// Reports an installed run of `count` items and records what is appended to
356
+ /// it. Every other member is inert — the window logic reads the run, the
357
+ /// leading identity and the fade flag, nothing else.
358
+ private final class WindowEngineStub: PlaybackEngine {
359
+ let installedCount: Int
360
+ var leadingIndex: Int = 0
361
+ var fadePending = false
362
+ /// `CrossfadeEngine.insertItems` refuses an anchor it does not hold; the
363
+ /// caller's recovery from that is a contract worth exercising.
364
+ var refuseInserts = false
365
+ /// The engine's own playhead, which a reinstall of the run it is already
366
+ /// rendering has to carry across.
367
+ var position: TimeInterval = 0
368
+ private(set) var inserted: [AVPlayerItem] = []
369
+ private(set) var didSetItems = false
370
+ private(set) var seatedAtPosition: TimeInterval?
371
+ private var items: [AVPlayerItem] = []
372
+
373
+ init(count: Int) {
374
+ self.installedCount = count
375
+ }
376
+
377
+ func stampIds(_ ids: [String]) {
378
+ items = ids.map { id in
379
+ let item = AVPlayerItem(url: URL(string: "https://example.com/\(id).m4a")!)
380
+ item.queueItemId = id
381
+ return item
382
+ }
383
+ }
384
+
385
+ var allMediaItems: [AVPlayerItem] { items }
386
+ var leadingQueueItemId: String? { items[safe: leadingIndex]?.queueItemId }
387
+ var isFadePendingOrActive: Bool { fadePending }
388
+ /// The whole installed run stands in for what is attached; this stub's
389
+ /// contract is the window, not the legs.
390
+ var attachedQueueItemIds: [String] { items.compactMap { $0.queueItemId } }
391
+
392
+ /// Inserts after the anchor, as the engines do, so the run's shape is
393
+ /// observable and not only the count of what was appended.
394
+ @discardableResult
395
+ func insertItems(_ newItems: [AVPlayerItem], after: AVPlayerItem?) -> Bool {
396
+ guard !refuseInserts else { return false }
397
+ let position: Int
398
+ if let after {
399
+ guard let index = items.firstIndex(where: { $0 === after }) else { return false }
400
+ position = index + 1
401
+ } else {
402
+ position = items.count
403
+ }
404
+ inserted.append(contentsOf: newItems)
405
+ items.insert(contentsOf: newItems, at: position)
406
+ return true
407
+ }
408
+
409
+ func setAudioMixProvider(_ provider: AudioMixProvider?) {}
410
+ func refreshAllItemMixes() {}
411
+ func release() {}
412
+ func setItems(_ items: [AVPlayerItem], startIndex: Int, startPositionSeconds: TimeInterval) {
413
+ didSetItems = true
414
+ seatedAtPosition = startPositionSeconds
415
+ self.items = items
416
+ }
417
+ func remove(_ item: AVPlayerItem) { items.removeAll { $0 === item } }
418
+ func removeAllItems() { items.removeAll() }
419
+ func canInsert(_ item: AVPlayerItem, after: AVPlayerItem?) -> Bool { true }
420
+ func play() {}
421
+ func pause() {}
422
+ func stop() {}
423
+ /// A seek moves the playhead: the run is measured from the new leading item.
424
+ func seek(toIndex index: Int, position: TimeInterval) { leadingIndex = index }
425
+ func skipToNext() {}
426
+ func skipToPrevious() {}
427
+ func setVolume(_ volume: Float) {}
428
+ func setPlaybackSpeed(_ speed: Float) {}
429
+ func seedPlaybackSpeed(_ speed: Float) {}
430
+ func setRepeatMode(_ mode: RepeatMode) {}
431
+ func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {}
432
+
433
+ var currentMediaItemIndex: Int { leadingIndex }
434
+ var currentPositionSeconds: TimeInterval { position }
435
+ var currentDurationSeconds: TimeInterval { 0 }
436
+ var bufferedPositionSeconds: TimeInterval { 0 }
437
+ var isPlaying: Bool { false }
438
+ var timeControlStatus: AVPlayer.TimeControlStatus { .paused }
439
+ var currentMediaItem: AVPlayerItem? { items[safe: leadingIndex] }
440
+ var delegate: PlaybackEngineDelegate?
441
+ }