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
@@ -25,9 +25,8 @@ private let ASYNC_DISPATCH_TIMEOUT: TimeInterval = 30.0
25
25
  /// look-back tail, the in-flight keep/cancel rule, and the
26
26
  /// drain-on-tearDown contract.
27
27
  ///
28
- /// `@MainActor` because `reschedule` / `cancel` / `tearDown` enforce
29
- /// `dispatchPrecondition(condition: .onQueue(.main))` tests must
30
- /// invoke them on main to match the production caller contract.
28
+ /// `@MainActor` because `reschedule` / `cancel` / `tearDown` assert the queue
29
+ /// the prefetcher was confined to, and these tests confine it to main.
31
30
  @MainActor
32
31
  final class LookaheadCachePrefetcherTests: XCTestCase {
33
32
 
@@ -135,9 +134,33 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
135
134
 
136
135
  // MARK: - Orchestration
137
136
 
137
+ func testRescheduleRunsOnTheInjectedQueueRatherThanMain() async {
138
+ let downloader = StubDownloader()
139
+ let confinement = DispatchQueue(label: "test.prefetcher.confinement")
140
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: confinement)
141
+ let tracks = (0 ..< 3).map { httpTrack("q\($0)") }
142
+
143
+ // The precondition inside `reschedule` fails the run if the caller is not
144
+ // on the confinement queue, so entering from it is the assertion.
145
+ let scheduled = expectation(description: "reschedule accepted from the injected queue")
146
+ // A `DispatchWorkItem` rather than a trailing closure: the prefetcher is
147
+ // confined to `confinement`, not shared across threads, so it does not
148
+ // belong in a `@Sendable` closure.
149
+ confinement.async(execute: DispatchWorkItem {
150
+ p.reschedule(tracks: tracks, currentIndex: 0, lookaheadCount: 2)
151
+ scheduled.fulfill()
152
+ })
153
+ await fulfillment(of: [scheduled], timeout: 2.0)
154
+ await p.awaitCurrent()
155
+
156
+ XCTAssertEqual(
157
+ downloader.invocations, ["q1", "q2"],
158
+ "the lookahead covers the tracks ahead of the current one, in order")
159
+ }
160
+
138
161
  func testRescheduleWithEmptyTargetsLeavesOrchestratorIdle() async {
139
162
  let downloader = StubDownloader()
140
- let p = LookaheadCachePrefetcher(downloader: downloader)
163
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
141
164
  p.reschedule(tracks: [], currentIndex: 0, lookaheadCount: 3)
142
165
  await p.awaitCurrent()
143
166
  XCTAssertFalse(p.hasInFlightTask)
@@ -146,7 +169,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
146
169
 
147
170
  func testRescheduleDispatchesDownloadsSequentiallyInQueueOrder() async {
148
171
  let downloader = StubDownloader()
149
- let p = LookaheadCachePrefetcher(downloader: downloader)
172
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
150
173
  let q = (0..<5).map { httpTrack("t\($0)") }
151
174
 
152
175
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -158,7 +181,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
158
181
 
159
182
  func testRescheduleSwallowsPerTrackErrorAndContinues() async {
160
183
  let downloader = StubDownloader(failingTrackIds: ["t2"])
161
- let p = LookaheadCachePrefetcher(downloader: downloader)
184
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
162
185
  let q = (0..<5).map { httpTrack("t\($0)") }
163
186
 
164
187
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -173,7 +196,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
173
196
  // download attempt without marking the URL "done"; the next
174
197
  // reschedule that still wants that URL must retry it.
175
198
  let downloader = StubDownloader(failingTrackIds: ["t2"])
176
- let p = LookaheadCachePrefetcher(downloader: downloader)
199
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
177
200
  let q = (0..<5).map { httpTrack("t\($0)") }
178
201
 
179
202
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -204,7 +227,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
204
227
  // leaving the prefetcher idle and ready for a fresh reschedule
205
228
  // when connectivity returns.
206
229
  let downloader = StubDownloader(failingTrackIds: ["t1", "t2", "t3"])
207
- let p = LookaheadCachePrefetcher(downloader: downloader)
230
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
208
231
  let q = (0..<5).map { httpTrack("t\($0)") }
209
232
 
210
233
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -237,7 +260,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
237
260
  onStart: { firstStarted.fulfill() },
238
261
  hold: firstHold
239
262
  )
240
- let p = LookaheadCachePrefetcher(downloader: downloader)
263
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
241
264
  let queue = [
242
265
  httpTrack("cur"), httpTrack("old-1"), httpTrack("old-2"),
243
266
  httpTrack("old-3"), httpTrack("old-4"),
@@ -268,7 +291,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
268
291
  onStart: { firstStarted.fulfill() },
269
292
  hold: firstHold
270
293
  )
271
- let p = LookaheadCachePrefetcher(downloader: downloader)
294
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
272
295
 
273
296
  p.reschedule(
274
297
  tracks: [httpTrack("cur"), httpTrack("old-1"), httpTrack("old-2")],
@@ -296,7 +319,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
296
319
 
297
320
  func testRescheduleAppendsLookbackAsLastTargetAfterForwardWindow() async {
298
321
  let downloader = StubDownloader()
299
- let p = LookaheadCachePrefetcher(downloader: downloader)
322
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
300
323
  let q = (0..<10).map { httpTrack("t\($0)") }
301
324
 
302
325
  // currentIndex = 5 → forward window [6,7,8] + look-back t0.
@@ -309,7 +332,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
309
332
 
310
333
  func testRescheduleOmitsLookbackWhenCurrentTrackIsTrackZero() async {
311
334
  let downloader = StubDownloader()
312
- let p = LookaheadCachePrefetcher(downloader: downloader)
335
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
313
336
  let q = (0..<5).map { httpTrack("t\($0)") }
314
337
 
315
338
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -321,7 +344,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
321
344
 
322
345
  func testRescheduleOmitsLookbackWhenTrackZeroIsNonRemote() async {
323
346
  let downloader = StubDownloader()
324
- let p = LookaheadCachePrefetcher(downloader: downloader)
347
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
325
348
  let q: [TrackItem] = [
326
349
  makeTrack(id: "local-0", url: "file:///tmp/0.m4a"),
327
350
  httpTrack("t1"), httpTrack("t2"), httpTrack("t3"), httpTrack("t4"),
@@ -338,7 +361,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
338
361
 
339
362
  func testRescheduleDoesNotReEnqueueAlreadyCachedUrl() async {
340
363
  let downloader = StubDownloader(cachedUrls: ["https://example.com/t2.m4a"])
341
- let p = LookaheadCachePrefetcher(downloader: downloader)
364
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
342
365
  let q = (0..<5).map { httpTrack("t\($0)") }
343
366
 
344
367
  p.reschedule(tracks: q, currentIndex: 0, lookaheadCount: 3)
@@ -355,7 +378,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
355
378
  // exercised on subsequent overlapping windows. Verify every
356
379
  // remote track within reach downloads exactly once.
357
380
  let downloader = TrackingDownloader()
358
- let p = LookaheadCachePrefetcher(downloader: downloader)
381
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
359
382
  let q = (0..<8).map { httpTrack("t\($0)") }
360
383
 
361
384
  for cur in 0..<7 {
@@ -388,7 +411,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
388
411
  onStart: { firstStarted.fulfill() },
389
412
  hold: firstHold
390
413
  )
391
- let p = LookaheadCachePrefetcher(downloader: downloader)
414
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
392
415
 
393
416
  p.reschedule(
394
417
  tracks: [httpTrack("cur"), httpTrack("old-1"), httpTrack("old-2")],
@@ -435,7 +458,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
435
458
  onStart: { started.fulfill() },
436
459
  hold: hold
437
460
  )
438
- let p = LookaheadCachePrefetcher(downloader: downloader)
461
+ let p = LookaheadCachePrefetcher(downloader: downloader, confinedTo: .main)
439
462
  p.reschedule(
440
463
  tracks: [httpTrack("cur"), httpTrack("gated"), httpTrack("after")],
441
464
  currentIndex: 0,
@@ -455,7 +478,7 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
455
478
  // MARK: - Helpers
456
479
 
457
480
  private func newPrefetcher() -> LookaheadCachePrefetcher {
458
- return LookaheadCachePrefetcher(downloader: StubDownloader())
481
+ return LookaheadCachePrefetcher(downloader: StubDownloader(), confinedTo: .main)
459
482
  }
460
483
 
461
484
  private func httpTrack(_ id: String) -> TrackItem {
@@ -465,7 +488,9 @@ final class LookaheadCachePrefetcherTests: XCTestCase {
465
488
  private func makeTrack(id: String, url: String) -> TrackItem {
466
489
  return TrackItem(
467
490
  id: id, url: url, title: nil, artist: nil, album: nil,
468
- duration: nil, artworkUrl: nil, extras: nil
491
+ duration: nil, artworkUrl: nil, extras: nil,
492
+ replayGainTrackGain: nil, replayGainTrackPeak: nil,
493
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil
469
494
  )
470
495
  }
471
496
  }
@@ -500,15 +525,15 @@ final class StubDownloader: LookaheadCacheDownloader, @unchecked Sendable {
500
525
  }
501
526
 
502
527
  func download(track: TrackItem, headers: [String: String]?) async throws {
503
- lock.lock()
504
528
  // Test fixtures supply non-nil track.id values; the `?? ""`
505
529
  // satisfies the optional type while keeping the assertions
506
530
  // simple. The lib itself never assumes track.id is non-empty —
507
531
  // these tests verify orchestration order using fixture-supplied
508
532
  // identifiers.
509
- _invocations.append(track.id ?? "")
510
- let shouldFail = _failingTrackIds.contains(track.id ?? "")
511
- lock.unlock()
533
+ let shouldFail = lock.withLock {
534
+ _invocations.append(track.id ?? "")
535
+ return _failingTrackIds.contains(track.id ?? "")
536
+ }
512
537
  if shouldFail {
513
538
  throw LookaheadCacheError.httpError(statusCode: 500)
514
539
  }
@@ -534,10 +559,10 @@ final class TrackingDownloader: LookaheadCacheDownloader, @unchecked Sendable {
534
559
  }
535
560
 
536
561
  func download(track: TrackItem, headers: [String: String]?) async throws {
537
- lock.lock()
538
- _invocations.append(track.id ?? "")
539
- _completed.insert(track.url)
540
- lock.unlock()
562
+ lock.withLock {
563
+ _invocations.append(track.id ?? "")
564
+ _completed.insert(track.url)
565
+ }
541
566
  }
542
567
 
543
568
  func isFullyCached(url: String) -> Bool {
@@ -570,10 +595,8 @@ final class GatedDownloader: LookaheadCacheDownloader, @unchecked Sendable {
570
595
  }
571
596
 
572
597
  func download(track: TrackItem, headers: [String: String]?) async throws {
573
- lock.lock()
574
598
  // See `StubDownloader.download` — same `?? ""` rationale.
575
- _invocations.append(track.id ?? "")
576
- lock.unlock()
599
+ lock.withLock { _invocations.append(track.id ?? "") }
577
600
  if track.id == gateTrackId {
578
601
  onStart()
579
602
  await hold.wait()
@@ -89,6 +89,27 @@ final class LookaheadCacheRuntimeConfigTests: XCTestCase {
89
89
  XCTAssertTrue(cache.isFullyCached(url: track.url))
90
90
  }
91
91
 
92
+ /// A disabled cache serves nothing, whatever is on disk; the file is still
93
+ /// there for the next enable.
94
+ func testADisabledCacheAnswersNoCachedURL() async throws {
95
+ let cache = LookaheadCache(
96
+ directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
97
+ )
98
+ let url = URL(string: "https://example.com/held.m4a")!
99
+ StubURLProtocol.stub(url: url, data: Data("payload".utf8), statusCode: 200)
100
+ try await cache.download(
101
+ track: makeTrack(id: "held", url: url.absoluteString), headers: nil
102
+ )
103
+ let fileURL = try XCTUnwrap(cache.cachedURL(forUrl: url.absoluteString))
104
+
105
+ cache.disable()
106
+ XCTAssertNil(cache.cachedURL(forUrl: url.absoluteString), "disabled means every request goes to the origin")
107
+ XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path), "the file waits for the next enable")
108
+
109
+ cache.enable()
110
+ XCTAssertEqual(cache.cachedURL(forUrl: url.absoluteString), fileURL)
111
+ }
112
+
92
113
  func testIsFullyCachedReturnsFalseWhenFileMissing() async throws {
93
114
  let cache = LookaheadCache(
94
115
  directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
@@ -164,6 +185,7 @@ final class LookaheadCacheRuntimeConfigTests: XCTestCase {
164
185
  )
165
186
  let prefetcher = LookaheadCachePrefetcher(
166
187
  cache: cache,
188
+ confinedTo: .main,
167
189
  configHeaders: ["X-Auth": "token-1"],
168
190
  configUserAgent: "rnqp/1"
169
191
  )
@@ -241,7 +263,144 @@ final class LookaheadCacheRuntimeConfigTests: XCTestCase {
241
263
  private func makeTrack(id: String, url: String) -> TrackItem {
242
264
  return TrackItem(
243
265
  id: id, url: url, title: nil, artist: nil, album: nil,
244
- duration: nil, artworkUrl: nil, extras: nil
266
+ duration: nil, artworkUrl: nil, extras: nil,
267
+ replayGainTrackGain: nil, replayGainTrackPeak: nil,
268
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil
269
+ )
270
+ }
271
+
272
+ // MARK: - The live protected set guards every eviction, not just a shrink
273
+
274
+ func testCommitEvictionSparesTheProtectedPlayingTrack() async throws {
275
+ // Budget fits two entries. The third commit forces an eviction, and the
276
+ // oldest entry is the one playing — the LRU victim without protection.
277
+ let cache = LookaheadCache(
278
+ directory: tempDir, maxSizeBytes: 8_000, urlSession: session
279
+ )
280
+ let playingUrl = "https://example.com/playing.m4a"
281
+ cache.setProtectedUrls([playingUrl])
282
+
283
+ for id in ["playing", "b", "c"] {
284
+ let url = URL(string: "https://example.com/\(id).m4a")!
285
+ StubURLProtocol.stub(url: url, data: Data(repeating: 0x41, count: 4_000), statusCode: 200)
286
+ try await cache.download(
287
+ track: makeTrack(id: id, url: url.absoluteString), headers: nil
288
+ )
289
+ }
290
+
291
+ XCTAssertTrue(
292
+ cache.isFullyCached(url: playingUrl),
293
+ "a prefetch commit must not evict the track that is playing"
245
294
  )
246
295
  }
296
+
297
+ func testCommitEvictionStillTakesUnprotectedEntries() async throws {
298
+ let cache = LookaheadCache(
299
+ directory: tempDir, maxSizeBytes: 8_000, urlSession: session
300
+ )
301
+ cache.setProtectedUrls([])
302
+
303
+ for id in ["a", "b", "c"] {
304
+ let url = URL(string: "https://example.com/\(id).m4a")!
305
+ StubURLProtocol.stub(url: url, data: Data(repeating: 0x41, count: 4_000), statusCode: 200)
306
+ try await cache.download(
307
+ track: makeTrack(id: id, url: url.absoluteString), headers: nil
308
+ )
309
+ }
310
+
311
+ XCTAssertLessThanOrEqual(
312
+ cache.currentSizeBytes(), 8_000,
313
+ "with nothing protected the budget must still be enforced"
314
+ )
315
+ XCTAssertFalse(
316
+ cache.isFullyCached(url: "https://example.com/a.m4a"),
317
+ "the oldest unprotected entry is still the eviction victim"
318
+ )
319
+ }
320
+
321
+ // MARK: - A download shorter than declared is never committed
322
+
323
+ private func downloadStubbed(
324
+ _ cache: LookaheadCache, id: String, body: Data, headers: [String: String]?
325
+ ) async throws {
326
+ let url = URL(string: "https://example.com/\(id).m4a")!
327
+ StubURLProtocol.stub(url: url, data: body, statusCode: 200, headerFields: headers)
328
+ try await cache.download(track: makeTrack(id: id, url: url.absoluteString), headers: nil)
329
+ }
330
+
331
+ func testTruncatedBodyIsRejected() async throws {
332
+ let cache = LookaheadCache(
333
+ directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
334
+ )
335
+ do {
336
+ try await downloadStubbed(
337
+ cache, id: "short", body: Data(repeating: 0x41, count: 400),
338
+ headers: ["Content-Length": "1000"])
339
+ XCTFail("a body shorter than the declared length must not be committed")
340
+ } catch LookaheadCacheError.truncatedBody(let expected, let received) {
341
+ XCTAssertEqual(expected, 1000)
342
+ XCTAssertEqual(received, 400)
343
+ }
344
+ XCTAssertFalse(
345
+ cache.isFullyCached(url: "https://example.com/short.m4a"),
346
+ "a rejected download must leave no cache entry to poison later plays")
347
+ }
348
+
349
+ func testExactLengthBodyIsAccepted() async throws {
350
+ let cache = LookaheadCache(
351
+ directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
352
+ )
353
+ try await downloadStubbed(
354
+ cache, id: "exact", body: Data(repeating: 0x41, count: 400),
355
+ headers: ["Content-Length": "400"])
356
+ XCTAssertTrue(cache.isFullyCached(url: "https://example.com/exact.m4a"))
357
+ }
358
+
359
+ func testChunkedBodyWithNoDeclaredLengthIsAccepted() async throws {
360
+ let cache = LookaheadCache(
361
+ directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
362
+ )
363
+ try await downloadStubbed(
364
+ cache, id: "chunked", body: Data(repeating: 0x41, count: 400), headers: nil)
365
+ XCTAssertTrue(
366
+ cache.isFullyCached(url: "https://example.com/chunked.m4a"),
367
+ "no declared length means nothing to compare against, not a rejection")
368
+ }
369
+
370
+ func testBodyLongerThanDeclaredIsAccepted() async throws {
371
+ let cache = LookaheadCache(
372
+ directory: tempDir, maxSizeBytes: 1024 * 1024, urlSession: session
373
+ )
374
+ // A decoded body can exceed the declared compressed length; only short
375
+ // is a truncation.
376
+ try await downloadStubbed(
377
+ cache, id: "longer", body: Data(repeating: 0x41, count: 400),
378
+ headers: ["Content-Length": "200"])
379
+ XCTAssertTrue(cache.isFullyCached(url: "https://example.com/longer.m4a"))
380
+ }
381
+
382
+ // MARK: - buildCacheStatusSnapshot
383
+
384
+ func testCacheStatusSnapshotReportsTheSuppliedInFlightUrl() {
385
+ let player = TrackPlayer()
386
+ let status = player.buildCacheStatusSnapshot(
387
+ inFlightUrl: "https://example.com/inflight.m4a")
388
+ XCTAssertEqual(status.currentlyCaching, ["https://example.com/inflight.m4a"])
389
+ }
390
+
391
+ func testCacheStatusSnapshotReportsNothingCachingWithoutAnInFlightUrl() {
392
+ let player = TrackPlayer()
393
+ XCTAssertEqual(player.buildCacheStatusSnapshot(inFlightUrl: nil).currentlyCaching, [])
394
+ }
395
+
396
+ func testCacheStatusSnapshotReportsTheDefaultCacheCeilingBeforeConfigure() {
397
+ let player = TrackPlayer()
398
+ let status = player.buildCacheStatusSnapshot(inFlightUrl: nil)
399
+ XCTAssertTrue(status.enabled)
400
+ XCTAssertEqual(status.currentSizeMb, 0)
401
+ XCTAssertEqual(status.tracksFullyCached, 0)
402
+ XCTAssertEqual(
403
+ status.maxSizeMb,
404
+ Double(LookaheadCache.DEFAULT_MAX_SIZE_BYTES) / Double(LookaheadCache.BYTES_PER_MB))
405
+ }
247
406
  }
@@ -240,6 +240,31 @@ final class LookaheadCacheTests: XCTestCase {
240
240
  XCTAssertNil(cache.cachedURL(forUrl: "wipe"))
241
241
  }
242
242
 
243
+ // MARK: - Teardown quiescence
244
+
245
+ func testDownloadAfterTearDownIsANoOp() async throws {
246
+ // A prefetcher Task can reach `download` after the owning player has
247
+ // torn the cache down. Creating a URLSession task on the invalidated
248
+ // session raises an Obj-C exception that no Swift `do-catch` can
249
+ // intercept, so the teardown has to quiesce the cache first.
250
+ let cache = LookaheadCache(
251
+ directory: tempDir, maxSizeBytes: LookaheadCache.DEFAULT_MAX_SIZE_BYTES,
252
+ urlSession: session)
253
+ let url = URL(string: "https://example.com/after-teardown.m4a")!
254
+ StubURLProtocol.stub(url: url, data: Data("x".utf8), statusCode: 200)
255
+ let track = makeTrack(id: "after-teardown", url: url.absoluteString)
256
+
257
+ cache.tearDown()
258
+ try await cache.download(track: track, headers: nil)
259
+
260
+ XCTAssertNil(
261
+ cache.cachedURL(forUrl: track.url),
262
+ "a download issued after tearDown must not populate the cache")
263
+ XCTAssertEqual(
264
+ StubURLProtocol.requestCount(for: url), 0,
265
+ "a download issued after tearDown must not reach the session")
266
+ }
267
+
243
268
  // MARK: - Eviction integration
244
269
 
245
270
  func testDownloadEvictsOldestWhenBudgetExceeded() async throws {
@@ -498,17 +523,16 @@ final class LookaheadCacheTests: XCTestCase {
498
523
 
499
524
  // MARK: - URL → bytes round-trip correctness
500
525
  //
501
- // These tests pin the contract that downloading URL A's bytes
526
+ // These tests cover the contract that downloading URL A's bytes
502
527
  // surfaces those exact bytes via cachedURL(forUrl: A) — and that
503
528
  // a sibling download of URL B doesn't disturb URL A's mapping.
504
- // Caught a regression where the lookahead-cache served the wrong
505
- // file's bytes after a sequence of forward + look-back prefetches.
529
+ // The cache serves each URL its own bytes after any sequence of
530
+ // forward and look-back prefetches.
506
531
 
507
532
  func testCachedFileBytesMatchSourceUrlBytes() async throws {
508
533
  // The fundamental contract: download URL A → cachedURL(A)'s file
509
534
  // contains A's bytes. No way for the cache to serve B's bytes
510
- // for A's URL. This is the core invariant the user-reported
511
- // wrong-file-mapping bug would violate.
535
+ // for A's URL. This is the core invariant.
512
536
  let cache = LookaheadCache(directory: tempDir, maxSizeBytes: LookaheadCache.DEFAULT_MAX_SIZE_BYTES, urlSession: session)
513
537
  let urlA = URL(string: "https://example.com/song-a.m4a")!
514
538
  let bytesA = Data("AAAAAAAAAAAAAAAAAAA-payload-for-track-A".utf8)
@@ -553,7 +577,7 @@ final class LookaheadCacheTests: XCTestCase {
553
577
  }
554
578
 
555
579
  func testLookbackPathPreservesPerUrlBytes() async throws {
556
- // Reproduces the user-reported flow: forward-prefetch t1/t2/t3,
580
+ // Forward-prefetch t1/t2/t3,
557
581
  // then look-back-prefetch t0. After the sequence, every URL
558
582
  // resolves to its own bytes — the look-back doesn't clobber the
559
583
  // forward window's mappings.
@@ -584,10 +608,9 @@ final class LookaheadCacheTests: XCTestCase {
584
608
  }
585
609
 
586
610
  func testFileNameUniquenessAcrossSimilarUrls() {
587
- // Belt-and-braces against any future change to fileName(forUrl:)
588
- // that might silently introduce collisions for URLs with similar
589
- // prefixes — e.g. Subsonic stream URLs that differ only in an
590
- // ?id=N query parameter.
611
+ // fileName(forUrl:) must not collide for URLs with similar
612
+ // prefixes e.g. stream URLs that differ only in an ?id=N query
613
+ // parameter.
591
614
  let urls = [
592
615
  "https://music.example.com/rest/stream?id=1&u=test",
593
616
  "https://music.example.com/rest/stream?id=2&u=test",
@@ -601,10 +624,8 @@ final class LookaheadCacheTests: XCTestCase {
601
624
 
602
625
  func testCollisionCandidateUrlsRoundTripDistinctBytes() async throws {
603
626
  // End-to-end correctness: download four URLs whose only difference
604
- // is an `?id=N` query parameter (a common Subsonic-style shape) and
605
- // verify each resolves to its own bytes. Catches a regression where
606
- // fileName uniqueness holds but `download` -> `cachedURL` keying
607
- // ever drifted to use a non-URL field.
627
+ // is an `?id=N` query parameter and verify each resolves to its own
628
+ // bytes: `download` and `cachedURL` must key on the same URL.
608
629
  let cache = LookaheadCache(directory: tempDir, maxSizeBytes: LookaheadCache.DEFAULT_MAX_SIZE_BYTES, urlSession: session)
609
630
  let urlBytes: [(url: URL, bytes: Data)] = [
610
631
  "https://music.example.com/rest/stream?id=1&u=test",
@@ -638,7 +659,9 @@ final class LookaheadCacheTests: XCTestCase {
638
659
  private func makeTrack(id: String, url: String) -> TrackItem {
639
660
  return TrackItem(
640
661
  id: id, url: url, title: nil, artist: nil, album: nil,
641
- duration: nil, artworkUrl: nil, extras: nil
662
+ duration: nil, artworkUrl: nil, extras: nil,
663
+ replayGainTrackGain: nil, replayGainTrackPeak: nil,
664
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil
642
665
  )
643
666
  }
644
667
  }
@@ -654,15 +677,18 @@ final class StubURLProtocol: URLProtocol {
654
677
  private struct Stub {
655
678
  let data: Data
656
679
  let statusCode: Int
680
+ let headerFields: [String: String]?
657
681
  }
658
682
 
659
683
  private static let lock = NSLock()
660
684
  private static var stubs: [URL: Stub] = [:]
661
685
  private static var requests: [URL: [URLRequest]] = [:]
662
686
 
663
- static func stub(url: URL, data: Data, statusCode: Int) {
687
+ static func stub(
688
+ url: URL, data: Data, statusCode: Int, headerFields: [String: String]? = nil
689
+ ) {
664
690
  lock.lock(); defer { lock.unlock() }
665
- stubs[url] = Stub(data: data, statusCode: statusCode)
691
+ stubs[url] = Stub(data: data, statusCode: statusCode, headerFields: headerFields)
666
692
  }
667
693
 
668
694
  static func reset() {
@@ -706,7 +732,7 @@ final class StubURLProtocol: URLProtocol {
706
732
 
707
733
  let response = HTTPURLResponse(
708
734
  url: url, statusCode: stub.statusCode,
709
- httpVersion: "HTTP/1.1", headerFields: nil
735
+ httpVersion: "HTTP/1.1", headerFields: stub.headerFields
710
736
  )!
711
737
  client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
712
738
  client?.urlProtocol(self, didLoad: stub.data)
@@ -161,3 +161,82 @@ final class MediaHTTPConnectionTests: XCTestCase {
161
161
  XCTAssertTrue(head.contains("Content-Length: 0\r\n"))
162
162
  }
163
163
  }
164
+
165
+ /// The head the read-through leg relays from the origin to the player.
166
+ ///
167
+ /// The player is AVFoundation reading media: it needs the status, the type, the
168
+ /// length and the range, and nothing else the origin chose to say.
169
+ final class MediaHTTPRelayHeadTests: XCTestCase {
170
+
171
+ private func response(
172
+ status: Int,
173
+ headers: [String: String]
174
+ ) -> HTTPURLResponse {
175
+ HTTPURLResponse(
176
+ url: URL(string: "https://example.com/track.mp3")!,
177
+ statusCode: status,
178
+ httpVersion: "HTTP/1.1",
179
+ headerFields: headers
180
+ )!
181
+ }
182
+
183
+ func testRelaysTheOriginStatusLine() {
184
+ let head = MediaHTTPConnection.relayHead(response(status: 206, headers: [:]))
185
+ XCTAssertTrue(head.hasPrefix("HTTP/1.1 206 Partial Content\r\n"), head)
186
+ }
187
+
188
+ /// The status line stays ASCII whatever the device locale; an unlisted
189
+ /// status carries an empty reason phrase, which HTTP/1.1 permits.
190
+ func testTheReasonPhraseIsFixedTextNotLocalisedText() {
191
+ XCTAssertEqual(MediaHTTPConnection.reasonPhrase(for: 404), "Not Found")
192
+ XCTAssertEqual(MediaHTTPConnection.reasonPhrase(for: 418), "")
193
+ let head = MediaHTTPConnection.relayHead(response(status: 418, headers: [:]))
194
+ XCTAssertTrue(head.hasPrefix("HTTP/1.1 418 \r\n"), head)
195
+ }
196
+
197
+ /// An HTTP error reaches the player as an HTTP error, so it classifies as the
198
+ /// server's answer rather than as a transport failure.
199
+ func testRelaysAnErrorStatusRatherThanMaskingIt() {
200
+ let head = MediaHTTPConnection.relayHead(response(status: 404, headers: [:]))
201
+ XCTAssertTrue(head.hasPrefix("HTTP/1.1 404 "), head)
202
+ }
203
+
204
+ func testCarriesTheHeadersThePlayerReadsMediaWith() {
205
+ let head = MediaHTTPConnection.relayHead(response(status: 206, headers: [
206
+ "Content-Type": "audio/mpeg",
207
+ "Content-Length": "1024",
208
+ "Content-Range": "bytes 0-1023/8192",
209
+ "Accept-Ranges": "bytes",
210
+ ]))
211
+ XCTAssertTrue(head.contains("Content-Type: audio/mpeg"), head)
212
+ XCTAssertTrue(head.contains("Content-Length: 1024"), head)
213
+ XCTAssertTrue(head.contains("Content-Range: bytes 0-1023/8192"), head)
214
+ XCTAssertTrue(head.contains("Accept-Ranges: bytes"), head)
215
+ }
216
+
217
+ func testDropsEverythingElseTheOriginSaid() {
218
+ let head = MediaHTTPConnection.relayHead(response(status: 200, headers: [
219
+ "Content-Type": "audio/mpeg",
220
+ "Set-Cookie": "session=secret",
221
+ "WWW-Authenticate": "Bearer realm=\"origin\"",
222
+ "Location": "https://elsewhere.example.com/track.mp3",
223
+ "Server": "origin/1.0",
224
+ ]))
225
+ XCTAssertFalse(head.lowercased().contains("set-cookie"), head)
226
+ XCTAssertFalse(head.lowercased().contains("www-authenticate"), head)
227
+ XCTAssertFalse(head.lowercased().contains("location"), head)
228
+ XCTAssertFalse(head.lowercased().contains("server:"), head)
229
+ }
230
+
231
+ func testHeadIsTerminatedSoABodyCanFollow() {
232
+ let head = MediaHTTPConnection.relayHead(response(status: 200, headers: ["Content-Length": "1"]))
233
+ XCTAssertTrue(head.hasSuffix("\r\n\r\n"), head.debugDescription)
234
+ }
235
+
236
+ /// An absent header is omitted rather than sent empty — a `Content-Range: `
237
+ /// with no value is a malformed response to a range request.
238
+ func testOmitsHeadersTheOriginDidNotSend() {
239
+ let head = MediaHTTPConnection.relayHead(response(status: 200, headers: ["Content-Type": "audio/mpeg"]))
240
+ XCTAssertFalse(head.contains("Content-Range"), head)
241
+ }
242
+ }