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
@@ -86,6 +86,9 @@ public final class LookaheadCache: @unchecked Sendable {
86
86
  /// scheduling immediately. Guarded by `queue`. The URLSession is
87
87
  /// only invalidated by `tearDown()` at full destroy.
88
88
  private var isEnabled: Bool = true
89
+ /// Downloads admitted and not yet finished; `tearDown` waits on it before
90
+ /// invalidating the session.
91
+ private let inFlightDownloads = DispatchGroup()
89
92
 
90
93
  // MARK: - Init / lifecycle
91
94
 
@@ -115,7 +118,8 @@ public final class LookaheadCache: @unchecked Sendable {
115
118
  internal convenience init(
116
119
  directory: URL = LookaheadCache.defaultDirectory(),
117
120
  maxSizeBytes: UInt64 = LookaheadCache.DEFAULT_MAX_SIZE_BYTES,
118
- evictionPolicy: EvictionPolicy = .lru
121
+ evictionPolicy: EvictionPolicy = .lru,
122
+ allowsCellularAccess: Bool = true
119
123
  ) {
120
124
  // `.ephemeral` rather than `.default` so the OS shared URL
121
125
  // response cache doesn't double-cache our prefetch bytes (we
@@ -136,10 +140,25 @@ public final class LookaheadCache: @unchecked Sendable {
136
140
  directory: directory,
137
141
  maxSizeBytes: maxSizeBytes,
138
142
  evictionPolicy: evictionPolicy,
139
- urlSession: URLSession(configuration: cfg)
143
+ urlSession: URLSession(configuration: cfg),
144
+ allowsCellularAccess: allowsCellularAccess
140
145
  )
141
146
  }
142
147
 
148
+ /// Whether prefetch downloads may use cellular. Set on each request rather
149
+ /// than baked into the session, so a change applies from the next request
150
+ /// without disturbing anything in flight — that one keeps the value it was
151
+ /// issued with. `URLRequest.allowsCellularAccess = false` is an
152
+ /// unconditional refusal, where the session's own value is only a
153
+ /// permissive default.
154
+ internal var allowsCellularAccess: Bool {
155
+ get { queue.sync { cellularAllowed } }
156
+ set { queue.sync { cellularAllowed = newValue } }
157
+ }
158
+
159
+ /// Guarded by `queue`, like the rest of the mutable cache state.
160
+ private var cellularAllowed: Bool = true
161
+
143
162
  /// Test-only init — tests inject a `URLSession` wired to a
144
163
  /// `URLProtocol` stub here. `internal` (not `@_spi public`)
145
164
  /// because `public` symbols still emit C++ interop bindings even
@@ -150,12 +169,14 @@ public final class LookaheadCache: @unchecked Sendable {
150
169
  directory: URL,
151
170
  maxSizeBytes: UInt64,
152
171
  evictionPolicy: EvictionPolicy = .lru,
153
- urlSession: URLSession
172
+ urlSession: URLSession,
173
+ allowsCellularAccess: Bool = true
154
174
  ) {
155
175
  self.cacheDirectory = directory
156
176
  self.maxBytes = maxSizeBytes
157
177
  self.evictionPolicy = evictionPolicy
158
178
  self.urlSession = urlSession
179
+ self.cellularAllowed = allowsCellularAccess
159
180
  Self.ensureDirectoryExists(at: directory)
160
181
  self.index = Self.loadOrCreateIndex(at: directory)
161
182
  }
@@ -165,12 +186,30 @@ public final class LookaheadCache: @unchecked Sendable {
165
186
  /// Must be called from `TrackPlayer.destroy` BEFORE any consumer
166
187
  /// drops their reference to the cache instance.
167
188
  ///
189
+ /// Clears `isEnabled` before invalidating, so a prefetcher Task
190
+ /// that reaches `download` after the teardown returns at the
191
+ /// quiescence gate instead of asking an invalidated session for a
192
+ /// task — which raises the Obj-C exception `Task created in a
193
+ /// session that has been invalidated`, uncatchable in Swift's
194
+ /// `do-catch`. Unlike `disable()` the session is not reusable
195
+ /// afterwards; a teardown ends the instance's life.
196
+ ///
168
197
  /// Named `tearDown` (not `release`) to avoid the Obj-C
169
198
  /// `NSObject.release` selector collision; pure Swift class so
170
199
  /// there is no actual NSObject to inherit, but staying clear
171
200
  /// of the name keeps things friendly if a future @objc bridge
172
201
  /// gets added.
173
202
  public func tearDown() {
203
+ queue.sync {
204
+ self.isEnabled = false
205
+ self.pendingDownloads.removeAll()
206
+ }
207
+ // A download admitted before the flag flipped may not have reached the
208
+ // session yet; asking an invalidated session for a task raises an
209
+ // Objective-C exception Swift cannot catch. The prefetcher is cancelled
210
+ // before this is called, so admitted downloads leave the group as their
211
+ // cancellation lands — the wait is a bound, not a normal cost.
212
+ _ = inFlightDownloads.wait(timeout: .now() + .seconds(2))
174
213
  urlSession.invalidateAndCancel()
175
214
  }
176
215
 
@@ -182,7 +221,8 @@ public final class LookaheadCache: @unchecked Sendable {
182
221
  /// `Task created in a session that has been invalidated`,
183
222
  /// uncatchable in Swift's `do-catch`).
184
223
  ///
185
- /// Effects: clears the `isEnabled` flag so `download` no-ops + the
224
+ /// Effects: clears the `isEnabled` flag so `download` no-ops, `cachedURL`
225
+ /// answers nil (the read-through server then serves from the origin) + the
186
226
  /// in-flight dedup set so a re-enable starts clean, and cancels
187
227
  /// any URLSession tasks already mid-flight via `getAllTasks`. The
188
228
  /// session itself stays alive — a later `enable()` reuses it.
@@ -205,19 +245,38 @@ public final class LookaheadCache: @unchecked Sendable {
205
245
  }
206
246
  }
207
247
 
248
+ /// URLs that must survive eviction: the playing track and the window
249
+ /// around it. Every eviction path consults this, not just a shrink —
250
+ /// `commitDownloadedFile` evicts after each prefetch commit, so without a
251
+ /// live set the entry backing the playing item can be deleted underneath
252
+ /// it and playback stops with a file that no longer exists.
253
+ ///
254
+ /// Written by `TrackPlayer` on track change and on prefetch reschedule.
255
+ /// Bounded by the caller: protecting the whole remaining queue would leave
256
+ /// nothing evictable and turn every commit into a full cache wipe.
257
+ private var liveProtectedUrls: Set<String> = []
258
+
259
+ /// Replace the set of URLs eviction must not touch.
260
+ ///
261
+ /// Asynchronous by design. The caller is `rescheduleLookahead`, which runs on
262
+ /// the queue serialising player state from every queue and skip path, and the
263
+ /// cache queue also serves download commits — a synchronous hop would park
264
+ /// that queue behind disk work on a hot path. The set is advisory for the next eviction, and
265
+ /// evictions run on this same serial queue, so a write enqueued here is
266
+ /// visible to every eviction that follows it.
267
+ public func setProtectedUrls(_ urls: Set<String>) {
268
+ queue.async { self.liveProtectedUrls = urls }
269
+ }
270
+
208
271
  /// Update the cache budget at runtime. On shrink, runs
209
- /// `evictUnderBudgetLocked` immediately so the new ceiling is
210
- /// enforced before the call returns. On grow, the new budget
211
- /// takes effect on the next download — no immediate eviction.
272
+ /// `evictUnderBudgetLocked` immediately so the new ceiling is enforced
273
+ /// before the call returns. On grow, the new budget takes effect on the next
274
+ /// download — no immediate eviction.
212
275
  ///
213
- /// `protectedUrls`: caller passes the source URLs whose
214
- /// `AVPlayerItem`s are currently held by AVQueuePlayer. Without
215
- /// this, a shrink could evict the file backing a live
216
- /// AVPlayerItem + manifest as `AVPlayerItemFailedToPlayToEndTime`
217
- /// mid-playback. TrackPlayer passes
218
- /// `tracks[currentTrackIndex...].map { $0.url }` so the entire
219
- /// active tail (current + buffered-ahead) is safe. Always keyed
220
- /// on URL — `track.id` is consumer-supplied + unreliable.
276
+ /// `protectedUrls` names the source URLs whose items the engine currently
277
+ /// holds. Without it a shrink can evict the file backing a live item and
278
+ /// surface as `AVPlayerItemFailedToPlayToEndTime` mid-playback. Always keyed
279
+ /// on URL: `track.id` is consumer-supplied and unreliable.
221
280
  public func setMaxSizeBytes(_ newSize: UInt64, protectedUrls: Set<String> = []) {
222
281
  let urlsToDeleteAfterUnlock: [URL] = queue.sync {
223
282
  let shrinking = newSize < self.maxBytes
@@ -254,30 +313,35 @@ public final class LookaheadCache: @unchecked Sendable {
254
313
  }
255
314
  }
256
315
 
257
- /// Number of cache index entries whose file is still on disk
258
- /// (queue-agnostic). Mirrors the semantics of `currentSizeBytes()`
259
- /// (cache state, independent of any current queue). Used by
260
- /// `TrackPlayer.buildCacheStatusSnapshot` for the
261
- /// `tracksFullyCached` field — the count reflects the cache itself,
262
- /// not the intersection with `self.tracks`.
316
+ /// Number of entries the cache index holds (queue-agnostic). Mirrors the
317
+ /// semantics of `currentSizeBytes()` — cache state, independent of any
318
+ /// current queue. Used by `TrackPlayer.buildCacheStatusSnapshot` for the
319
+ /// `tracksFullyCached` field, so the count reflects the cache itself, not
320
+ /// the intersection with `self.tracks`.
321
+ ///
322
+ /// The index is the source of truth, exactly as it is for
323
+ /// `currentSizeBytes()`, so this is O(1) and holds the lock briefly: a
324
+ /// cache-status subscriber has it fire after every prefetch iteration, and
325
+ /// stating the disk per entry would park the caller on the queue committing
326
+ /// those downloads. It therefore over-reports a file removed out from under
327
+ /// the index. Where the answer has to be exact about a single file — deciding
328
+ /// whether to play from cache — `isFullyCached(url:)` stats it.
329
+ ///
330
+ /// Android's counterpart verifies bytes on disk per key, because Media3
331
+ /// caches partial chunks and counting keys there would over-report a
332
+ /// half-downloaded track rather than a deleted one.
263
333
  public func fullyCachedCount() -> Int {
264
- return queue.sync {
265
- self.index.entries.reduce(into: 0) { acc, entry in
266
- let fileURL = cacheDirectory.appendingPathComponent(entry.fileName)
267
- if FileManager.default.fileExists(atPath: fileURL.path) {
268
- acc += 1
269
- }
270
- }
271
- }
334
+ return queue.sync { self.index.entries.count }
272
335
  }
273
336
 
274
337
  /// Wipe every cached file + reset the index.
275
338
  ///
276
- /// **Mid-playback caveat:** if a currently-playing `AVPlayerItem`
277
- /// was constructed from a cached file URL, deleting that file
278
- /// mid-playback surfaces as an `AVPlayerItemFailedToPlayToEndTime`
279
- /// error. Contract is "do not call during active playback";
280
- /// consumers should `pause()` first or accept the mid-track error.
339
+ /// Safe during playback. A playing item is bound to the read-through server
340
+ /// rather than to a cache file, so a track whose file is deleted underneath
341
+ /// it reads through to the origin on its next request instead of failing —
342
+ /// the cost is re-fetching the remainder, not an
343
+ /// `AVPlayerItemFailedToPlayToEndTime`. A response already in flight
344
+ /// completes, because the open handle holds the unlinked inode.
281
345
  public func clear() {
282
346
  let urlsToDelete: [URL] = queue.sync {
283
347
  let urls = self.index.entries.map {
@@ -299,14 +363,14 @@ public final class LookaheadCache: @unchecked Sendable {
299
363
 
300
364
  // MARK: - Lookup
301
365
 
302
- /// Return the local file URL for `url` (the source URL — see
303
- /// "Key shape" on `isFullyCached(url:)`) if a fully-downloaded
304
- /// cache entry exists, otherwise nil. Pure read — does NOT touch
305
- /// the LRU access tick. Callers that want LRU bookkeeping (the
306
- /// `AVQueueBuilder.makeItem` cache-hit branch is the production
307
- /// caller) call `touch(url:)` separately. Splitting these
308
- /// out avoids the prior pattern's two-FS-writes-per-read penalty
309
- /// on the playback hot path.
366
+ /// Return the local file URL for `url` (the source URL — the same
367
+ /// string `isFullyCached(url:)` keys on) if the cache is enabled and a
368
+ /// fully-downloaded entry exists, otherwise nil. Pure read — does NOT touch the LRU
369
+ /// access tick. The external production caller is the read-through server's
370
+ /// per-request `cachedFileForOrigin` lookup (built in
371
+ /// `TrackPlayer.startReadThroughServer`), which calls `touch(url:)`
372
+ /// once per token on its first disk hit, so a track's many range
373
+ /// requests cost one index write rather than one per read.
310
374
  ///
311
375
  /// Defensive: if the file got pruned out from under us (consumer
312
376
  /// ran `rm -rf tmp`, or eviction then crashed before
@@ -319,6 +383,9 @@ public final class LookaheadCache: @unchecked Sendable {
319
383
  /// for the lifetime of this process.
320
384
  public func cachedURL(forUrl url: String) -> URL? {
321
385
  return queue.sync {
386
+ // A disabled cache serves nothing: every request goes to the origin,
387
+ // whatever is on disk. The files stay for the next enable.
388
+ guard self.isEnabled else { return nil }
322
389
  guard let entryIdx = self.index.entries.firstIndex(where: { $0.trackId == url }) else {
323
390
  return nil
324
391
  }
@@ -379,7 +446,12 @@ public final class LookaheadCache: @unchecked Sendable {
379
446
  // that survived `disable()` + tried to download against the
380
447
  // (intentionally NOT invalidated) session still skips the
381
448
  // request rather than competing with a re-enable cycle.
382
- let enabled = queue.sync { self.isEnabled }
449
+ //
450
+ // The cellular policy is read here too, so one download cannot straddle a
451
+ // change: it is issued wholly under the policy in force when it started.
452
+ let (enabled, cellularAllowed) = queue.sync {
453
+ (self.isEnabled, self.cellularAllowed)
454
+ }
383
455
  guard enabled else { return }
384
456
 
385
457
  // Cache key = source URL. The `Entry` struct's `trackId` field
@@ -407,18 +479,34 @@ public final class LookaheadCache: @unchecked Sendable {
407
479
  // contract in `LookaheadCachePrefetcher` (failed tracks retry
408
480
  // on the next reschedule).
409
481
  let dedupeKey = track.url
410
- let alreadyPending: Bool = queue.sync {
411
- if self.pendingDownloads.contains(dedupeKey) { return true }
482
+ // Re-read `isEnabled` in the same hop that claims the dedup slot. The read
483
+ // above is separated from here by two more hops, and `tearDown` takes this
484
+ // same queue to invalidate the session — asking an invalidated session for
485
+ // a task raises an Objective-C exception Swift cannot catch. Admission and
486
+ // the enabled check have to be one decision, not two.
487
+ let admitted: Bool = queue.sync {
488
+ guard self.isEnabled, !self.pendingDownloads.contains(dedupeKey) else {
489
+ return false
490
+ }
412
491
  self.pendingDownloads.insert(dedupeKey)
413
- return false
492
+ // Entered under the same decision, so `tearDown` sees this download as
493
+ // in flight from the moment it is admitted.
494
+ self.inFlightDownloads.enter()
495
+ return true
414
496
  }
415
- if alreadyPending { return }
497
+ guard admitted else { return }
416
498
  defer {
499
+ inFlightDownloads.leave()
417
500
  queue.async { _ = self.pendingDownloads.remove(dedupeKey) }
418
501
  }
419
502
 
503
+ // Teardown cancels the prefetcher before invalidating the session, and
504
+ // cancellation is cooperative — this is the checkpoint that honours it, so
505
+ // a task admitted a moment before teardown does not reach the session.
506
+ try Task.checkCancellation()
507
+
420
508
  let (tempLocation, sizeBytes, contentType) = try await performDownload(
421
- url: url, headers: headers
509
+ url: url, headers: headers, allowsCellularAccess: cellularAllowed
422
510
  )
423
511
  try Task.checkCancellation()
424
512
 
@@ -431,16 +519,47 @@ public final class LookaheadCache: @unchecked Sendable {
431
519
  )
432
520
  }
433
521
 
434
- /// Run the URLSession download + HTTP-status validation. Returns
435
- /// the temp location URLSession wrote to + the measured size in
436
- /// bytes (so the commit step doesn't re-stat the file). Throws
437
- /// `LookaheadCacheError.httpError` for non-2xx responses (and
438
- /// cleans up the temp file before throwing).
522
+ /// Enough of a body to carry any container signature the media check reads.
523
+ private static let mediaCheckHeadBytes = 64
524
+
525
+ /// The first `bytes` of a file, or empty when it cannot be read. An
526
+ /// unreadable head is not itself a rejection — the size check above has
527
+ /// already established the file is there.
528
+ private static func head(ofFileAt url: URL, bytes: Int) -> Data {
529
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return Data() }
530
+ defer { try? handle.close() }
531
+ return (try? handle.read(upToCount: bytes)) ?? Data()
532
+ }
533
+
534
+ /// Remove a rejected download's temp file. Cleanup failures are logged
535
+ /// rather than thrown — the caller is already failing the download, and a
536
+ /// leaked temp file should be diagnosable without masking that reason.
537
+ private func discardTemp(at location: URL, reason: String) {
538
+ do {
539
+ try FileManager.default.removeItem(at: location)
540
+ } catch {
541
+ NSLog(
542
+ "[LookaheadCache] temp-file cleanup failed (%@) at %@: %@",
543
+ reason, location.path, String(describing: error)
544
+ )
545
+ }
546
+ }
547
+
548
+ /// Run the URLSession download and HTTP-status validation. Returns the temp
549
+ /// location URLSession wrote to plus the measured size in bytes, so the
550
+ /// commit step does not re-stat the file. Throws
551
+ /// `LookaheadCacheError.httpError` for non-2xx responses, cleaning up the
552
+ /// temp file before throwing.
439
553
  private func performDownload(
440
- url: URL, headers: [String: String]?
554
+ url: URL, headers: [String: String]?, allowsCellularAccess: Bool
441
555
  ) async throws -> (tempLocation: URL, sizeBytes: UInt64, contentType: String?) {
442
556
  var request = URLRequest(url: url)
443
557
  headers?.forEach { request.addValue($1, forHTTPHeaderField: $0) }
558
+ // Prefetch downloads whole upcoming tracks, so on a metered connection it
559
+ // can spend a lot of data on tracks the listener skips past. `false` here
560
+ // is an unconditional refusal — the request fails with
561
+ // `NSURLErrorDataNotAllowed` rather than falling back to cellular.
562
+ request.allowsCellularAccess = allowsCellularAccess
444
563
 
445
564
  let (tempLocation, response) = try await urlSession.download(for: request)
446
565
 
@@ -462,9 +581,47 @@ public final class LookaheadCache: @unchecked Sendable {
462
581
  )
463
582
  }
464
583
 
465
- let attrs = try? FileManager.default.attributesOfItem(atPath: tempLocation.path)
466
- let sizeBytes: UInt64 = (attrs?[.size] as? NSNumber)?.uint64Value ?? 0
584
+ // A 206 to a request that carried no `Range` is a partial body nobody
585
+ // asked for short by construction.
586
+ if httpResponse.statusCode == 206,
587
+ request.value(forHTTPHeaderField: "Range") == nil {
588
+ discardTemp(at: tempLocation, reason: "unexpected 206")
589
+ throw LookaheadCacheError.unexpectedPartialContent
590
+ }
591
+
592
+ // Read the size explicitly rather than defaulting a failure to zero: a
593
+ // `?? 0` turns an unreadable attribute on a perfectly good download into
594
+ // a rejection once the size is checked below.
595
+ guard let attrs = try? FileManager.default.attributesOfItem(atPath: tempLocation.path),
596
+ let sizeNumber = attrs[.size] as? NSNumber else {
597
+ discardTemp(at: tempLocation, reason: "size unreadable")
598
+ throw LookaheadCacheError.sizeUnreadable
599
+ }
600
+ let sizeBytes = sizeNumber.uint64Value
601
+
602
+ // Reject only when the body is SHORTER than declared. Stated this way the
603
+ // check is immune to Content-Encoding: a decoded body longer than the
604
+ // declared compressed length is not a truncation. `expectedContentLength`
605
+ // is -1 when the server chunked the response, which carries no length to
606
+ // compare against.
607
+ let expected = httpResponse.expectedContentLength
608
+ if expected > 0, sizeBytes < UInt64(expected) {
609
+ discardTemp(at: tempLocation, reason: "short body")
610
+ throw LookaheadCacheError.truncatedBody(expected: expected, received: sizeBytes)
611
+ }
612
+
467
613
  let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")
614
+ // A 2xx of the right length can still be a server's error envelope rather
615
+ // than a track. Committing one puts a permanent non-audio file behind a
616
+ // "fully cached" entry, which outlives the server being well again.
617
+ if let rejection = MediaResponseCheck.rejection(
618
+ contentType: contentType,
619
+ head: Self.head(ofFileAt: tempLocation, bytes: Self.mediaCheckHeadBytes),
620
+ headIsStartOfResource: request.value(forHTTPHeaderField: "Range") == nil
621
+ ) {
622
+ discardTemp(at: tempLocation, reason: "non-media body")
623
+ throw LookaheadCacheError.nonMediaBody(rejection)
624
+ }
468
625
  return (tempLocation, sizeBytes, contentType)
469
626
  }
470
627
 
@@ -546,7 +703,7 @@ public final class LookaheadCache: @unchecked Sendable {
546
703
  entries: self.index.entries,
547
704
  maxBytes: self.maxBytes,
548
705
  useFifoOrder: self.evictionPolicy == .fifo,
549
- protectedUrls: protectedUrls
706
+ protectedUrls: protectedUrls.union(self.liveProtectedUrls)
550
707
  )
551
708
  if evicted.isEmpty { return [] }
552
709
  let evictedIds = Set(evicted.map { $0.trackId })
@@ -682,7 +839,7 @@ public final class LookaheadCache: @unchecked Sendable {
682
839
  /// Map an HTTP `Content-Type` value to a file-extension hint that
683
840
  /// AVFoundation recognises. Returns `nil` for unknown / missing
684
841
  /// types so the caller can fall through to URL-derived hints.
685
- /// `application/octet-stream` (default for some Subsonic configs)
842
+ /// `application/octet-stream` (the default on some media servers)
686
843
  /// is treated as unknown — caller falls back to URL hints.
687
844
  @_spi(QueuePlayerTests)
688
845
  public static func extensionFromMimeType(_ contentType: String?) -> String? {
@@ -807,6 +964,20 @@ public enum LookaheadCacheError: Error {
807
964
  /// `cachedURL` lookups return nil and the cache-status snapshot
808
965
  /// lies. See `download(track:headers:)` for the throw site.
809
966
  case moveFailed(underlying: Error)
967
+ /// Body was shorter than the response declared. Committing it would put a
968
+ /// truncated file behind a "fully cached" entry, and the item built from
969
+ /// it fails to decode on every play and every retry.
970
+ case truncatedBody(expected: Int64, received: UInt64)
971
+ /// Server answered a partial body to a request that asked for the whole
972
+ /// file, so the download is short by construction.
973
+ case unexpectedPartialContent
974
+ /// The downloaded file's size could not be read, so the truncation check
975
+ /// cannot run; the file is discarded rather than committed unchecked.
976
+ case sizeUnreadable
977
+ /// The body is a document rather than media — typically a server reporting
978
+ /// an API-level failure as a `200` carrying an XML or JSON envelope.
979
+ /// Committing it would serve that envelope as the track on every later play.
980
+ case nonMediaBody(MediaResponseCheck.Rejection)
810
981
  }
811
982
 
812
983
  /// Abstraction over `LookaheadCache.download` so the prefetcher
@@ -55,21 +55,25 @@ public final class LookaheadCachePrefetcher {
55
55
 
56
56
  /// Headers + UA snapshot used for prefetch downloads. Read on
57
57
  /// every download call so a runtime-config update can swap
58
- /// the snapshot atomically. Swift's `var` is property-observer
59
- /// safe because writes happen only on main (TrackPlayer.configure
60
- /// hops to main first).
58
+ /// the snapshot atomically. Written from `configure`, which runs on the
59
+ /// same queue this prefetcher is confined to.
61
60
  internal var configHeaders: [String: String]?
62
61
  internal var configUserAgent: String?
63
62
 
64
- /// Source URL of the in-flight download, or `nil` when no
65
- /// download is mid-flight. Used by the orchestrator to decide
66
- /// whether to cancel + by `reschedule` to know which URL is
67
- /// already covered. Updated on main inside the orchestrator
68
- /// loop body.
63
+ /// The queue every access to this prefetcher's state runs on. Nothing here
64
+ /// touches UI — the work is URLSession downloads and file IO through a cache
65
+ /// that serialises itself so it is confined to whichever queue owns player
66
+ /// state rather than to main. That keeps it on the same queue as the callers
67
+ /// which cancel and tear it down, whose ordering against the cache's
68
+ /// URLSession invalidation has to hold.
69
+ private let queue: DispatchQueue
70
+
71
+ /// Source URL of the in-flight download, or `nil` when nothing is
72
+ /// mid-flight. The orchestrator reads it to decide whether to cancel, and
73
+ /// `reschedule` to know which URL is already covered.
69
74
  ///
70
- /// **Threading contract:** all reads + writes happen on main.
71
- /// Documented-only; `@MainActor` would cascade through the call
72
- /// chain.
75
+ /// Guarded by `queue`, not by an isolation annotation: a `@MainActor` or
76
+ /// actor declaration here would cascade through every caller.
73
77
  private var inFlightUrl: String?
74
78
 
75
79
  /// The Task running the in-flight download. Held so a `reschedule`
@@ -139,10 +143,12 @@ public final class LookaheadCachePrefetcher {
139
143
  /// each download.
140
144
  public init(
141
145
  cache: LookaheadCache,
146
+ confinedTo queue: DispatchQueue,
142
147
  configHeaders: [String: String]? = nil,
143
148
  configUserAgent: String? = nil
144
149
  ) {
145
150
  self.cache = cache
151
+ self.queue = queue
146
152
  self.configHeaders = configHeaders
147
153
  self.configUserAgent = configUserAgent
148
154
  }
@@ -154,10 +160,12 @@ public final class LookaheadCachePrefetcher {
154
160
  @_spi(QueuePlayerTests)
155
161
  public init(
156
162
  downloader: LookaheadCacheDownloader,
163
+ confinedTo queue: DispatchQueue,
157
164
  configHeaders: [String: String]? = nil,
158
165
  configUserAgent: String? = nil
159
166
  ) {
160
167
  self.cache = downloader
168
+ self.queue = queue
161
169
  self.configHeaders = configHeaders
162
170
  self.configUserAgent = configUserAgent
163
171
  }
@@ -177,7 +185,7 @@ public final class LookaheadCachePrefetcher {
177
185
  /// 2. `cache.tearDown()` — URLSession invalidated; any bytes
178
186
  /// still on the wire abort.
179
187
  public func cancel() {
180
- dispatchPrecondition(condition: .onQueue(.main))
188
+ dispatchPrecondition(condition: .onQueue(queue))
181
189
  pendingTargets.removeAll()
182
190
  inFlightTask?.cancel()
183
191
  inFlightTask = nil
@@ -195,7 +203,7 @@ public final class LookaheadCachePrefetcher {
195
203
  /// `LookaheadCache.tearDown` so the prefetch loop can observe
196
204
  /// cancellation cleanly before its underlying URLSession goes away.
197
205
  public func tearDown() {
198
- dispatchPrecondition(condition: .onQueue(.main))
206
+ dispatchPrecondition(condition: .onQueue(queue))
199
207
  cancel()
200
208
  }
201
209
 
@@ -215,7 +223,7 @@ public final class LookaheadCachePrefetcher {
215
223
  currentIndex: Int,
216
224
  lookaheadCount: Int? = nil
217
225
  ) {
218
- dispatchPrecondition(condition: .onQueue(.main))
226
+ dispatchPrecondition(condition: .onQueue(queue))
219
227
  let count = lookaheadCount ?? defaultLookaheadCount
220
228
  var desired = computeTargets(
221
229
  tracks: tracks, currentIndex: currentIndex, lookaheadCount: count
@@ -307,13 +315,6 @@ public final class LookaheadCachePrefetcher {
307
315
  return orchestrator != nil || inFlightTask != nil
308
316
  }
309
317
 
310
- /// Test introspection — count of URLs queued behind the in-flight
311
- /// download.
312
- @_spi(QueuePlayerTests)
313
- public var pendingCount: Int {
314
- return pendingTargets.count
315
- }
316
-
317
318
  /// Test introspection — await the orchestrator so test
318
319
  /// assertions can fire after the prefetch chain drains.
319
320
  @_spi(QueuePlayerTests)
@@ -323,6 +324,15 @@ public final class LookaheadCachePrefetcher {
323
324
 
324
325
  // MARK: - Orchestrator
325
326
 
327
+ /// Run `body` on the confinement queue and await its result. The orchestrator
328
+ /// itself runs on the cooperative pool, so every touch of this object's state
329
+ /// comes back through here. Bodies keep their own `[weak self]` capture.
330
+ private func onQueue<T>(_ body: @escaping () -> T) async -> T {
331
+ await withCheckedContinuation { (cont: CheckedContinuation<T, Never>) in
332
+ queue.async { cont.resume(returning: body()) }
333
+ }
334
+ }
335
+
326
336
  /// Launch a fresh orchestrator if none is running. Capturing
327
337
  /// `self` weakly keeps the prefetcher releaseable mid-loop —
328
338
  /// tearDown nils the orchestrator reference and the loop body
@@ -336,7 +346,7 @@ public final class LookaheadCachePrefetcher {
336
346
  isOrchestratorRunning = true
337
347
  orchestrator = Task { [weak self] in
338
348
  await self?.runOrchestrator()
339
- await MainActor.run { [weak self] in
349
+ await self?.onQueue { [weak self] in
340
350
  guard let self else { return }
341
351
  // Identity check: only clear ownership if this trailing hook
342
352
  // belongs to the still-current orchestrator. If a `cancel()`
@@ -362,11 +372,11 @@ public final class LookaheadCachePrefetcher {
362
372
  }
363
373
 
364
374
  /// Drain `pendingTargets` one URL at a time. Pops the next track
365
- /// on main, downloads it on the cooperative pool, then loops.
375
+ /// on its owner queue, downloads it on the cooperative pool, then loops.
366
376
  /// Exits when `pendingTargets` is empty or the Task is cancelled.
367
377
  private func runOrchestrator() async {
368
378
  while !Task.isCancelled {
369
- let next: TrackItem? = await MainActor.run { [weak self] () -> TrackItem? in
379
+ let next: TrackItem? = await onQueue { [weak self] () -> TrackItem? in
370
380
  guard let self = self else { return nil }
371
381
  if self.pendingTargets.isEmpty { return nil }
372
382
  return self.pendingTargets.removeFirst()
@@ -378,11 +388,11 @@ public final class LookaheadCachePrefetcher {
378
388
  // `reschedule` enqueued the URL just before a download via a
379
389
  // different path completed.
380
390
  if cache.isFullyCached(url: track.url) {
381
- await MainActor.run { [weak self] in self?.onTrackProcessed?() }
391
+ await onQueue { [weak self] in self?.onTrackProcessed?() }
382
392
  continue
383
393
  }
384
394
 
385
- let headers = await MainActor.run { [weak self] () -> [String: String] in
395
+ let headers = await onQueue { [weak self] () -> [String: String] in
386
396
  guard let self = self else { return [:] }
387
397
  return Self.merge(
388
398
  headers: self.configHeaders, userAgent: self.configUserAgent
@@ -392,7 +402,7 @@ public final class LookaheadCachePrefetcher {
392
402
  // Build the Task suspended via an explicit start barrier so
393
403
  // `inFlightUrl` / `inFlightTask` publish before the download
394
404
  // body executes. Without the barrier the eager-start Task can
395
- // race the MainActor.run setter — observers polling the
405
+ // race the queue-hop setter — observers polling the
396
406
  // currently-downloading URL after the gated downloader fires
397
407
  // its onStart can otherwise see a stale `nil`.
398
408
  let startBarrier = AsyncBarrier()
@@ -400,7 +410,7 @@ public final class LookaheadCachePrefetcher {
400
410
  await startBarrier.wait()
401
411
  try await cache.download(track: track, headers: headers)
402
412
  }
403
- await MainActor.run { [weak self] in
413
+ await onQueue { [weak self] in
404
414
  self?.inFlightUrl = track.url
405
415
  self?.inFlightTask = downloadTask
406
416
  }
@@ -415,7 +425,7 @@ public final class LookaheadCachePrefetcher {
415
425
  logSwallowed(track: track, error: error)
416
426
  }
417
427
 
418
- await MainActor.run { [weak self] in
428
+ await onQueue { [weak self] in
419
429
  self?.inFlightUrl = nil
420
430
  self?.inFlightTask = nil
421
431
  self?.onTrackProcessed?()
@@ -434,14 +444,16 @@ public final class LookaheadCachePrefetcher {
434
444
  private static func merge(
435
445
  headers: [String: String]?, userAgent: String?
436
446
  ) -> [String: String] {
437
- var merged = headers ?? [:]
438
- if let ua = userAgent, !ua.isEmpty {
439
- merged["User-Agent"] = ua
440
- }
441
- return merged
447
+ AVQueueBuilder.mergedHeaders(headers: headers, userAgent: userAgent)
442
448
  }
443
449
 
444
450
  private func logSwallowed(track: TrackItem, error: Error) {
451
+ // A consumer who turned `allowsCellularAccess` off gets this on every
452
+ // reschedule made while the device is on cellular. It is the policy
453
+ // working, not a fault, so it does not go to the log.
454
+ let nsError = error as NSError
455
+ if nsError.domain == NSURLErrorDomain,
456
+ nsError.code == NSURLErrorDataNotAllowed { return }
445
457
  // Log by source URL (the trustworthy field). `track.id` is
446
458
  // optional consumer data — a missing or empty value would
447
459
  // produce an unhelpful log line.
@@ -460,7 +472,7 @@ public final class LookaheadCachePrefetcher {
460
472
  }
461
473
 
462
474
  /// Internal one-shot barrier used by the orchestrator so the
463
- /// in-flight URL publishes on main BEFORE the download body
475
+ /// in-flight URL publishes on the owner queue BEFORE the download body
464
476
  /// executes. `release()` is idempotent + non-blocking; `wait()`
465
477
  /// suspends until the first `release()` and resumes immediately
466
478
  /// for any subsequent `wait()` (no producer needed).
@@ -469,7 +481,7 @@ public final class LookaheadCachePrefetcher {
469
481
  /// the download Deferred to achieve the same publish-before-body
470
482
  /// semantics. Swift Tasks have no equivalent lazy-start option, so
471
483
  /// the orchestrator constructs the Task suspended on this barrier,
472
- /// publishes `inFlightUrl` / `inFlightTask` on main, then releases.
484
+ /// publishes `inFlightUrl` / `inFlightTask` on the owner queue, then releases.
473
485
  private final class AsyncBarrier: @unchecked Sendable {
474
486
  private let lock = NSLock()
475
487
  private var continuation: CheckedContinuation<Void, Never>?