react-native-queue-player 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (355) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +839 -271
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  58. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  59. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  60. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  61. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  109. package/app.plugin.js +17 -1
  110. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  111. package/ios/AVQueueBuilder.swift +120 -85
  112. package/ios/ArtworkLoader.swift +0 -7
  113. package/ios/ArtworkResolver.swift +22 -4
  114. package/ios/AssetReplayGainReader.swift +118 -0
  115. package/ios/AudioSession.swift +67 -16
  116. package/ios/AudioTapProvider.swift +284 -149
  117. package/ios/CarPlayBridge.swift +0 -12
  118. package/ios/CarPlayCoordinator.swift +18 -2
  119. package/ios/CarPlaySceneDelegate.swift +2 -2
  120. package/ios/Cast/AirPlayRouteState.swift +9 -8
  121. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  122. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  123. package/ios/Cast/Core/CastSession.swift +2 -2
  124. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  125. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  126. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  127. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  128. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  129. package/ios/CastManager.swift +1 -1
  130. package/ios/CrossfadeEngine.swift +660 -226
  131. package/ios/EQTap.swift +20 -17
  132. package/ios/Equalizer.swift +1 -1
  133. package/ios/FLACStreamInfo.swift +65 -0
  134. package/ios/GaplessEngine.swift +57 -30
  135. package/ios/InputGuards.swift +35 -5
  136. package/ios/LookaheadCache.swift +202 -58
  137. package/ios/LookaheadCachePrefetcher.swift +48 -36
  138. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  139. package/ios/NetworkRecoveryPolicy.swift +32 -0
  140. package/ios/NowPlayingFormatExtractor.swift +25 -18
  141. package/ios/NowPlayingInfo.swift +84 -48
  142. package/ios/OutputRouteMonitor.swift +16 -3
  143. package/ios/PendingBrowseRequests.swift +1 -1
  144. package/ios/PlaceholderArtwork.swift +9 -5
  145. package/ios/PlaybackEngine.swift +165 -76
  146. package/ios/PlaybackErrorMapping.swift +3 -3
  147. package/ios/PlaybackModeStateMachine.swift +2 -2
  148. package/ios/PlaybackNetworkMonitor.swift +73 -0
  149. package/ios/PlayerStateDerivation.swift +6 -6
  150. package/ios/QueueMutationArithmetic.swift +25 -0
  151. package/ios/QueueSkipArithmetic.swift +5 -3
  152. package/ios/QueueWindowArithmetic.swift +152 -0
  153. package/ios/ReadThroughServer.swift +283 -0
  154. package/ios/RemoteCommands.swift +44 -4
  155. package/ios/ReplayGainData.swift +22 -3
  156. package/ios/ReplayGainExtractor.swift +70 -27
  157. package/ios/Siri/VoiceDonation.swift +14 -9
  158. package/ios/StreamingBitrateProbe.swift +22 -68
  159. package/ios/SuppliedReplayGain.swift +83 -0
  160. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  161. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  162. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  163. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  164. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  165. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  166. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  167. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  168. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  169. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  170. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  171. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  172. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  173. package/ios/Tests/EngineSwapTests.swift +91 -0
  174. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  175. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  176. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  177. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  178. package/ios/Tests/InputGuardsTests.swift +41 -0
  179. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  180. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  181. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  182. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  183. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  184. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  185. package/ios/Tests/MutationDeferralTests.swift +220 -0
  186. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  187. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  188. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  189. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  190. package/ios/Tests/PlayerFixtures.swift +48 -0
  191. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  192. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  193. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  194. package/ios/Tests/QueueStateTests.swift +3 -1
  195. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  196. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  197. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  198. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  199. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  200. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  201. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  202. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  203. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  204. package/ios/Tests/SkipIndexTests.swift +32 -2
  205. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  206. package/ios/Tests/StallRecoveryTests.swift +97 -0
  207. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  208. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  209. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  210. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  211. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  212. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  213. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  214. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  215. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  216. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  217. package/ios/TrackPlayer+Automotive.swift +101 -0
  218. package/ios/TrackPlayer+Cache.swift +232 -0
  219. package/ios/TrackPlayer+Config.swift +291 -0
  220. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  221. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  222. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  223. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  224. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  225. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  226. package/ios/TrackPlayer+Queue.swift +940 -0
  227. package/ios/TrackPlayer+Recovery.swift +196 -0
  228. package/ios/TrackPlayer+Skip.swift +455 -0
  229. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  230. package/ios/TrackPlayer+State.swift +108 -0
  231. package/ios/TrackPlayer+Threading.swift +135 -0
  232. package/ios/TrackPlayer+Transport.swift +267 -0
  233. package/ios/TrackPlayer+Window.swift +195 -0
  234. package/ios/TrackPlayer.swift +305 -4619
  235. package/ios/Visualizer.swift +6 -5
  236. package/ios/tests-harness/Podfile +1 -1
  237. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  238. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  239. package/lib/module/hooks/useActiveTrack.js +29 -22
  240. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  241. package/lib/module/hooks/useCast.js +8 -26
  242. package/lib/module/hooks/useCast.js.map +1 -1
  243. package/lib/module/hooks/useEqualizer.js +19 -12
  244. package/lib/module/hooks/useEqualizer.js.map +1 -1
  245. package/lib/module/hooks/useLookaheadCache.js +3 -7
  246. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  247. package/lib/module/hooks/useQueue.js +66 -19
  248. package/lib/module/hooks/useQueue.js.map +1 -1
  249. package/lib/module/index.js +6 -3
  250. package/lib/module/index.js.map +1 -1
  251. package/lib/module/queueDelta.js +41 -0
  252. package/lib/module/queueDelta.js.map +1 -0
  253. package/lib/module/types.js +28 -5
  254. package/lib/module/types.js.map +1 -1
  255. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  256. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  257. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  258. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  259. package/lib/typescript/hooks/useCast.d.ts +6 -1
  260. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  261. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  262. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  263. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  265. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  266. package/lib/typescript/index.d.ts +2 -1
  267. package/lib/typescript/index.d.ts.map +1 -1
  268. package/lib/typescript/queueDelta.d.ts +10 -0
  269. package/lib/typescript/queueDelta.d.ts.map +1 -0
  270. package/lib/typescript/types.d.ts +168 -12
  271. package/lib/typescript/types.d.ts.map +1 -1
  272. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  273. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  274. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  275. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  276. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  277. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  278. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  279. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  280. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  281. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  313. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  314. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  315. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  316. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  317. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  318. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  319. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  320. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  321. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  322. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  323. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  324. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  325. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  326. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  327. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  328. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  329. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  330. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  331. package/package.json +8 -8
  332. package/src/TrackPlayer.nitro.ts +53 -27
  333. package/src/hooks/useActiveTrack.ts +29 -22
  334. package/src/hooks/useCast.ts +14 -15
  335. package/src/hooks/useEqualizer.ts +19 -12
  336. package/src/hooks/useLookaheadCache.ts +3 -7
  337. package/src/hooks/useQueue.ts +66 -17
  338. package/src/index.ts +13 -3
  339. package/src/queueDelta.ts +41 -0
  340. package/src/types.ts +169 -12
  341. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  342. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  343. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  344. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  345. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  346. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  347. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  348. package/ios/MetadataReader.swift +0 -555
  349. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  350. package/ios/Tests/MetadataReaderTests.swift +0 -603
  351. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  352. package/ios/Tests/VoiceDonationTests.swift +0 -26
  353. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  354. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  355. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,345 @@
1
+ import Network
2
+ import XCTest
3
+ @testable import QueuePlayer
4
+
5
+ /// The loopback server the player reads every remote track through.
6
+ ///
7
+ /// These drive a real listener over a real socket, because what matters here is
8
+ /// what an HTTP client actually receives — the byte-range maths is covered by
9
+ /// `MediaHTTPConnectionTests`.
10
+ final class ReadThroughServerTests: XCTestCase {
11
+
12
+ private var tempDir: URL!
13
+
14
+ override func setUpWithError() throws {
15
+ tempDir = FileManager.default.temporaryDirectory
16
+ .appendingPathComponent("readthrough-tests-\(UUID().uuidString)")
17
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
18
+ }
19
+
20
+ override func tearDownWithError() throws {
21
+ try? FileManager.default.removeItem(at: tempDir)
22
+ }
23
+
24
+ private func writeFile(_ bytes: [UInt8], named name: String = "track.mp3") throws -> URL {
25
+ let url = tempDir.appendingPathComponent(name)
26
+ try Data(bytes).write(to: url)
27
+ return url
28
+ }
29
+
30
+ /// An origin that cannot be reached, for the paths where the cache must be
31
+ /// what answers — a request that reached the network would fail loudly.
32
+ private let unreachableOrigin = URL(string: "http://127.0.0.1:1/never.mp3")!
33
+
34
+ private func makeServer(
35
+ cachedFile: @escaping (String) -> URL? = { _ in nil },
36
+ onFirstDiskHit: @escaping (String) -> Void = { _ in }
37
+ ) async -> ReadThroughServer {
38
+ let server = ReadThroughServer(
39
+ cachedFileForOrigin: cachedFile,
40
+ onFirstDiskHit: onFirstDiskHit
41
+ )
42
+ server.start()
43
+ XCTAssertTrue(server.waitUntilBound(), "the loopback listener binds within the wait")
44
+ return server
45
+ }
46
+
47
+ /// The cache is keyed on the consumer's `track.url` verbatim; the lookup
48
+ /// receives that string, not the origin URL's re-serialisation.
49
+ func testTheCacheLookupReceivesTheSourceStringVerbatim() async throws {
50
+ var asked: [String] = []
51
+ let server = await makeServer(cachedFile: { asked.append($0); return nil })
52
+ defer { server.stop() }
53
+ // Loopback port 1 refuses at once; the space is what the assertion needs.
54
+ let raw = "http://127.0.0.1:1/a b.mp3"
55
+ let origin = try XCTUnwrap(URL(string: raw))
56
+ let local = try XCTUnwrap(server.localURL(for: origin, sourceURL: raw))
57
+
58
+ _ = try? await get(local)
59
+
60
+ // The refused origin drops the connection and URLSession retries the
61
+ // request, so the lookup runs once per attempt; the string is what matters.
62
+ XCTAssertFalse(asked.isEmpty)
63
+ XCTAssertEqual(Set(asked), [raw])
64
+ XCTAssertNotEqual(origin.absoluteString, raw, "the origin re-serialises the space; the key must not")
65
+ }
66
+
67
+ /// A raw HTTP client that reads `chunk` bytes at a time with a pause between
68
+ /// reads, so the relay is what fills up rather than the client. Returns the
69
+ /// body once `Content-Length` bytes have arrived.
70
+ private func slowGet(_ url: URL, chunk: Int, pauseNanoseconds: UInt64) async throws -> Data {
71
+ let host = try XCTUnwrap(url.host)
72
+ let port = try XCTUnwrap(NWEndpoint.Port(rawValue: UInt16(try XCTUnwrap(url.port))))
73
+ let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: .tcp)
74
+ defer { connection.cancel() }
75
+ connection.start(queue: DispatchQueue(label: "slow-get"))
76
+ // A relay that stops delivering would otherwise block the receive forever.
77
+ let deadline = DispatchWorkItem { connection.cancel() }
78
+ DispatchQueue.global().asyncAfter(deadline: .now() + 30, execute: deadline)
79
+ defer { deadline.cancel() }
80
+ let request = "GET \(url.path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n"
81
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
82
+ connection.send(content: Data(request.utf8), completion: .contentProcessed { error in
83
+ if let error { cont.resume(throwing: error) } else { cont.resume() }
84
+ })
85
+ }
86
+ var received = Data()
87
+ var bodyStart: Int?
88
+ var contentLength: Int?
89
+ while true {
90
+ let (piece, complete) = try await withCheckedThrowingContinuation {
91
+ (cont: CheckedContinuation<(Data?, Bool), Error>) in
92
+ connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) {
93
+ data, _, isComplete, error in
94
+ if let error { cont.resume(throwing: error) } else { cont.resume(returning: (data, isComplete)) }
95
+ }
96
+ }
97
+ if let piece { received.append(piece) }
98
+ if bodyStart == nil, let headerEnd = received.range(of: Data("\r\n\r\n".utf8)) {
99
+ bodyStart = headerEnd.upperBound
100
+ let head = String(decoding: received[..<headerEnd.lowerBound], as: UTF8.self)
101
+ let lengthLine = head.split(separator: "\r\n").first { $0.lowercased().hasPrefix("content-length:") }
102
+ contentLength = lengthLine.flatMap { Int($0.split(separator: ":")[1].trimmingCharacters(in: .whitespaces)) }
103
+ }
104
+ if let bodyStart, let contentLength, received.count - bodyStart >= contentLength {
105
+ return received[bodyStart ..< bodyStart + contentLength]
106
+ }
107
+ if complete { throw URLError(.networkConnectionLost) }
108
+ try await Task.sleep(nanoseconds: pauseNanoseconds)
109
+ }
110
+ }
111
+
112
+ private func get(_ url: URL, range: String? = nil) async throws -> (HTTPURLResponse, Data) {
113
+ var request = URLRequest(url: url)
114
+ if let range { request.setValue(range, forHTTPHeaderField: "Range") }
115
+ let config = URLSessionConfiguration.ephemeral
116
+ config.timeoutIntervalForRequest = 10
117
+ let (data, response) = try await URLSession(configuration: config).data(for: request)
118
+ return (response as! HTTPURLResponse, data)
119
+ }
120
+
121
+ // MARK: - Routing
122
+
123
+ func testNoLocalURLBeforeTheListenerIsUp() {
124
+ let server = ReadThroughServer(cachedFileForOrigin: { _ in nil }, onFirstDiskHit: { _ in })
125
+ XCTAssertNil(
126
+ server.localURL(for: unreachableOrigin),
127
+ "an unbound server must report no URL so the caller keeps the origin")
128
+ }
129
+
130
+ func testTheURLIsLoopbackAndCarriesA32HexToken() async {
131
+ let server = await makeServer()
132
+ defer { server.stop() }
133
+ let url = server.localURL(for: unreachableOrigin)
134
+ XCTAssertEqual(url?.host, "127.0.0.1")
135
+ let token = url?.lastPathComponent ?? ""
136
+ XCTAssertEqual(token.count, 32, token)
137
+ XCTAssertTrue(token.allSatisfy { $0.isHexDigit }, token)
138
+ }
139
+
140
+ /// One token per origin for the life of the session. A window rebuild hands
141
+ /// the player the URL it already holds, so the asset never re-points and
142
+ /// AVFoundation keeps reusing its own connections.
143
+ func testTheSameOriginAlwaysGetsTheSameURL() async {
144
+ let server = await makeServer()
145
+ defer { server.stop() }
146
+ let first = server.localURL(for: unreachableOrigin)
147
+ let second = server.localURL(for: unreachableOrigin)
148
+ XCTAssertEqual(first, second)
149
+ }
150
+
151
+ func testDifferentOriginsGetDifferentTokens() async {
152
+ let server = await makeServer()
153
+ defer { server.stop() }
154
+ let a = server.localURL(for: URL(string: "https://example.com/a.mp3")!)
155
+ let b = server.localURL(for: URL(string: "https://example.com/b.mp3")!)
156
+ XCTAssertNotEqual(a, b)
157
+ }
158
+
159
+ // MARK: - Serving
160
+
161
+ func testAFullyCachedFileIsServedFromDisk() async throws {
162
+ let bytes = (0 ..< 2048).map { UInt8($0 % 251) }
163
+ let file = try writeFile(bytes)
164
+ let server = await makeServer(cachedFile: { _ in file })
165
+ defer { server.stop() }
166
+ let url = try XCTUnwrap(server.localURL(for: unreachableOrigin))
167
+
168
+ let (response, data) = try await get(url)
169
+
170
+ XCTAssertEqual(response.statusCode, 200)
171
+ XCTAssertEqual([UInt8](data), bytes, "the bytes served are not the cached file's")
172
+ }
173
+
174
+ func testARangeRequestOnACachedFileGetsThatRange() async throws {
175
+ let bytes = (0 ..< 2048).map { UInt8($0 % 251) }
176
+ let file = try writeFile(bytes)
177
+ let server = await makeServer(cachedFile: { _ in file })
178
+ defer { server.stop() }
179
+ let url = try XCTUnwrap(server.localURL(for: unreachableOrigin))
180
+
181
+ let (response, data) = try await get(url, range: "bytes=100-199")
182
+
183
+ XCTAssertEqual(response.statusCode, 206)
184
+ XCTAssertEqual(response.value(forHTTPHeaderField: "Content-Range"), "bytes 100-199/2048")
185
+ XCTAssertEqual([UInt8](data), Array(bytes[100 ... 199]))
186
+ }
187
+
188
+ /// AVFoundation opens every remote asset with this probe and reads the total
189
+ /// size out of the `Content-Range`.
190
+ func testTheContentInfoProbeIsAnsweredWithTheResourceSize() async throws {
191
+ let file = try writeFile((0 ..< 5000).map { UInt8($0 % 251) })
192
+ let server = await makeServer(cachedFile: { _ in file })
193
+ defer { server.stop() }
194
+ let url = try XCTUnwrap(server.localURL(for: unreachableOrigin))
195
+
196
+ let (response, data) = try await get(url, range: "bytes=0-1")
197
+
198
+ XCTAssertEqual(response.statusCode, 206)
199
+ XCTAssertEqual(response.value(forHTTPHeaderField: "Content-Range"), "bytes 0-1/5000")
200
+ XCTAssertEqual(data.count, 2)
201
+ }
202
+
203
+ func testAnUnknownTokenIs404() async throws {
204
+ let server = await makeServer()
205
+ defer { server.stop() }
206
+ let port = server.boundPortValue()
207
+ let url = URL(string: "http://127.0.0.1:\(port)/media/\(String(repeating: "a", count: 32))")!
208
+
209
+ let (response, _) = try await get(url)
210
+
211
+ XCTAssertEqual(response.statusCode, 404)
212
+ }
213
+
214
+ /// The client names a token and nothing else. A path or query that carried a
215
+ /// URL would make this an open relay that fetches whatever it is told with
216
+ /// the consumer's credentials attached.
217
+ func testAPathThatNamesAURLIsRejected() async throws {
218
+ let server = await makeServer()
219
+ defer { server.stop() }
220
+ let port = server.boundPortValue()
221
+ let url = URL(string: "http://127.0.0.1:\(port)/media/https%3A%2F%2Fevil.example.com%2Fx.mp3")!
222
+
223
+ let (response, _) = try await get(url)
224
+
225
+ XCTAssertEqual(response.statusCode, 404)
226
+ }
227
+
228
+ /// A cache entry whose file has gone reads through instead of failing, so a
229
+ /// `clear()` during playback costs a re-fetch rather than a dead item.
230
+ func testAMissingCacheFileFallsThroughToTheOrigin() async throws {
231
+ let vanished = tempDir.appendingPathComponent("not-written.mp3")
232
+ let server = await makeServer(cachedFile: { _ in vanished })
233
+ defer { server.stop() }
234
+ let url = try XCTUnwrap(server.localURL(for: unreachableOrigin))
235
+
236
+ // The origin is unreachable, so falling through surfaces as the server
237
+ // closing the connection without a response — the origin leg was tried
238
+ // and failed. A 404 would mean it never tried.
239
+ do {
240
+ let (response, _) = try await get(url)
241
+ XCTFail(
242
+ "a missing cache file must read through to the origin; got HTTP \(response.statusCode) instead of a dropped connection")
243
+ } catch let error as URLError {
244
+ XCTAssertEqual(
245
+ error.code, .networkConnectionLost,
246
+ "expected the origin leg to close the connection without a reply")
247
+ }
248
+ }
249
+
250
+ /// A cache miss relays the origin's body, in order and to the last byte. A
251
+ /// second loopback server serving the file from disk stands in as the origin,
252
+ /// so the relay leg runs over a real socket in both directions. The body is
253
+ /// larger than the relay's read buffer, so the bytes cross more than one
254
+ /// delivery.
255
+ func testACacheMissRelaysTheOriginBodyIntact() async throws {
256
+ let bytes = (0 ..< 3_000_000).map { UInt8($0 % 251) }
257
+ let file = try writeFile(bytes)
258
+ let origin = await makeServer(cachedFile: { _ in file })
259
+ defer { origin.stop() }
260
+ let originURL = try XCTUnwrap(origin.localURL(for: unreachableOrigin))
261
+ let relay = await makeServer(cachedFile: { _ in nil })
262
+ defer { relay.stop() }
263
+ let url = try XCTUnwrap(relay.localURL(for: originURL))
264
+
265
+ let (response, data) = try await get(url)
266
+
267
+ XCTAssertEqual(response.statusCode, 200)
268
+ XCTAssertEqual(data.count, bytes.count)
269
+ XCTAssertEqual([UInt8](data), bytes, "the relayed bytes are not the origin's")
270
+ }
271
+
272
+ /// The playback case: AVFoundation reads at playback rate while the origin
273
+ /// delivers as fast as the network allows. The relay holds a bounded amount
274
+ /// and stops reading the origin until the client has drained it — and then
275
+ /// reads again, so a slow client still receives the whole body.
276
+ func testASlowClientStillReceivesTheWholeRelayedBody() async throws {
277
+ let bytes = (0 ..< 6_000_000).map { UInt8($0 % 251) }
278
+ let file = try writeFile(bytes)
279
+ let origin = await makeServer(cachedFile: { _ in file })
280
+ defer { origin.stop() }
281
+ let originURL = try XCTUnwrap(origin.localURL(for: unreachableOrigin))
282
+ let relay = await makeServer(cachedFile: { _ in nil })
283
+ defer { relay.stop() }
284
+ let url = try XCTUnwrap(relay.localURL(for: originURL))
285
+
286
+ let body = try await slowGet(url, chunk: 64 * 1024, pauseNanoseconds: 3_000_000)
287
+
288
+ XCTAssertEqual(body.count, bytes.count)
289
+ XCTAssertEqual([UInt8](body), bytes, "the relayed bytes are not the origin's")
290
+ }
291
+
292
+ /// The range a client asks for is what the origin is asked for.
293
+ func testACacheMissRelaysARangeRequestToTheOrigin() async throws {
294
+ let bytes = (0 ..< 2048).map { UInt8($0 % 251) }
295
+ let file = try writeFile(bytes)
296
+ let origin = await makeServer(cachedFile: { _ in file })
297
+ defer { origin.stop() }
298
+ let originURL = try XCTUnwrap(origin.localURL(for: unreachableOrigin))
299
+ let relay = await makeServer(cachedFile: { _ in nil })
300
+ defer { relay.stop() }
301
+ let url = try XCTUnwrap(relay.localURL(for: originURL))
302
+
303
+ let (response, data) = try await get(url, range: "bytes=100-199")
304
+
305
+ XCTAssertEqual(response.statusCode, 206)
306
+ XCTAssertEqual(response.value(forHTTPHeaderField: "Content-Range"), "bytes 100-199/2048")
307
+ XCTAssertEqual([UInt8](data), Array(bytes[100 ... 199]))
308
+ }
309
+
310
+ /// LRU is bumped when a file is actually read, once per token — `touch`
311
+ /// writes the index on the same queue the lookup takes, and a player issues
312
+ /// several range requests per track.
313
+ func testTheCacheIsTouchedOncePerTokenNotPerRequest() async throws {
314
+ let file = try writeFile((0 ..< 512).map { UInt8($0 % 251) })
315
+ let touches = TouchCounter()
316
+ let server = await makeServer(cachedFile: { _ in file }, onFirstDiskHit: { _ in touches.bump() })
317
+ defer { server.stop() }
318
+ let url = try XCTUnwrap(server.localURL(for: unreachableOrigin))
319
+
320
+ _ = try await get(url, range: "bytes=0-1")
321
+ _ = try await get(url, range: "bytes=0-99")
322
+ _ = try await get(url)
323
+
324
+ XCTAssertEqual(touches.value, 1, "three requests for one token bumped LRU more than once")
325
+ }
326
+
327
+ func testStoppingDropsEveryToken() async throws {
328
+ let server = await makeServer()
329
+ XCTAssertNotNil(server.localURL(for: unreachableOrigin), "precondition: a bound server routes")
330
+ server.stop()
331
+
332
+ XCTAssertNil(
333
+ server.localURL(for: unreachableOrigin),
334
+ "a stopped server must not hand out URLs pointing at a dead port")
335
+ XCTAssertEqual(server.boundPortValue(), 0)
336
+ }
337
+ }
338
+
339
+ /// Counts callbacks from connection queues.
340
+ private final class TouchCounter: @unchecked Sendable {
341
+ private let lock = NSLock()
342
+ private var count = 0
343
+ func bump() { lock.lock(); count += 1; lock.unlock() }
344
+ var value: Int { lock.lock(); defer { lock.unlock() }; return count }
345
+ }
@@ -24,6 +24,7 @@ final class RemoteCommandsTests: XCTestCase {
24
24
 
25
25
  override func tearDown() {
26
26
  commands.uninstall()
27
+ drainMain()
27
28
  commands = nil
28
29
  resetSharedCenter()
29
30
  super.tearDown()
@@ -34,6 +35,7 @@ final class RemoteCommandsTests: XCTestCase {
34
35
  func testInstallEnablesAllOwnedCommands() {
35
36
  let center = MPRemoteCommandCenter.shared()
36
37
  commands.install()
38
+ drainMain()
37
39
  XCTAssertTrue(center.playCommand.isEnabled)
38
40
  XCTAssertTrue(center.pauseCommand.isEnabled)
39
41
  XCTAssertTrue(center.togglePlayPauseCommand.isEnabled)
@@ -46,7 +48,9 @@ final class RemoteCommandsTests: XCTestCase {
46
48
  func testUninstallDisablesAllOwnedCommands() {
47
49
  let center = MPRemoteCommandCenter.shared()
48
50
  commands.install()
51
+ drainMain()
49
52
  commands.uninstall()
53
+ drainMain()
50
54
  XCTAssertFalse(center.playCommand.isEnabled)
51
55
  XCTAssertFalse(center.pauseCommand.isEnabled)
52
56
  XCTAssertFalse(center.togglePlayPauseCommand.isEnabled)
@@ -59,10 +63,13 @@ final class RemoteCommandsTests: XCTestCase {
59
63
  func testInstallTwiceLeavesEnabledStatesIntact() {
60
64
  let center = MPRemoteCommandCenter.shared()
61
65
  commands.install()
66
+ drainMain()
62
67
  commands.install()
68
+ drainMain()
63
69
  XCTAssertTrue(center.playCommand.isEnabled)
64
70
  XCTAssertTrue(center.nextTrackCommand.isEnabled)
65
71
  commands.uninstall()
72
+ drainMain()
66
73
  XCTAssertFalse(center.playCommand.isEnabled)
67
74
  }
68
75
 
@@ -70,7 +77,9 @@ final class RemoteCommandsTests: XCTestCase {
70
77
  let center = MPRemoteCommandCenter.shared()
71
78
  let priorPlay = center.playCommand.isEnabled
72
79
  commands.uninstall()
80
+ drainMain()
73
81
  commands.uninstall()
82
+ drainMain()
74
83
  XCTAssertEqual(center.playCommand.isEnabled, priorPlay)
75
84
  }
76
85
 
@@ -79,7 +88,9 @@ final class RemoteCommandsTests: XCTestCase {
79
88
  center.seekForwardCommand.isEnabled = true
80
89
  center.skipBackwardCommand.isEnabled = true
81
90
  commands.install()
91
+ drainMain()
82
92
  commands.uninstall()
93
+ drainMain()
83
94
  XCTAssertTrue(center.seekForwardCommand.isEnabled,
84
95
  "seekForward is not lib-owned; uninstall must leave it alone")
85
96
  XCTAssertTrue(center.skipBackwardCommand.isEnabled,
@@ -93,6 +104,7 @@ final class RemoteCommandsTests: XCTestCase {
93
104
  let priorNext = center.nextTrackCommand.isEnabled
94
105
  let priorPrev = center.previousTrackCommand.isEnabled
95
106
  commands.setSkipCapability(canSkipNext: !priorNext, canSkipPrevious: !priorPrev)
107
+ drainMain()
96
108
  XCTAssertEqual(center.nextTrackCommand.isEnabled, priorNext)
97
109
  XCTAssertEqual(center.previousTrackCommand.isEnabled, priorPrev)
98
110
  }
@@ -100,10 +112,13 @@ final class RemoteCommandsTests: XCTestCase {
100
112
  func testSetSkipCapabilityFlipsIsEnabledWhenInstalled() {
101
113
  let center = MPRemoteCommandCenter.shared()
102
114
  commands.install()
115
+ drainMain()
103
116
  commands.setSkipCapability(canSkipNext: false, canSkipPrevious: false)
117
+ drainMain()
104
118
  XCTAssertFalse(center.nextTrackCommand.isEnabled)
105
119
  XCTAssertFalse(center.previousTrackCommand.isEnabled)
106
120
  commands.setSkipCapability(canSkipNext: true, canSkipPrevious: true)
121
+ drainMain()
107
122
  XCTAssertTrue(center.nextTrackCommand.isEnabled)
108
123
  XCTAssertTrue(center.previousTrackCommand.isEnabled)
109
124
  }
@@ -111,10 +126,13 @@ final class RemoteCommandsTests: XCTestCase {
111
126
  func testSetSkipCapabilityNoOpAfterUninstall() {
112
127
  let center = MPRemoteCommandCenter.shared()
113
128
  commands.install()
129
+ drainMain()
114
130
  commands.uninstall()
131
+ drainMain()
115
132
  let priorNext = center.nextTrackCommand.isEnabled
116
133
  let priorPrev = center.previousTrackCommand.isEnabled
117
134
  commands.setSkipCapability(canSkipNext: true, canSkipPrevious: true)
135
+ drainMain()
118
136
  XCTAssertEqual(center.nextTrackCommand.isEnabled, priorNext)
119
137
  XCTAssertEqual(center.previousTrackCommand.isEnabled, priorPrev)
120
138
  }
@@ -141,4 +159,14 @@ final class RemoteCommandsTests: XCTestCase {
141
159
  cmd.isEnabled = false
142
160
  }
143
161
  }
162
+
163
+ /// Lets the command-centre writes handed to main land. `RemoteCommands`
164
+ /// enqueues them rather than applying them inline, so an assertion taken in
165
+ /// the same turn still sees the previous state.
166
+ private func drainMain() {
167
+ let drained = expectation(description: "main queue drained")
168
+ DispatchQueue.main.async { drained.fulfill() }
169
+ wait(for: [drained], timeout: 2.0)
170
+ }
171
+
144
172
  }