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
@@ -57,7 +57,7 @@ public abstract class HeadlessJsMediaService :
57
57
  internal val activeTasks: MutableSet<Int> = CopyOnWriteArraySet()
58
58
 
59
59
  /**
60
- * Latch flipped on the first task spawn so a re-entrant
60
+ * Set on the first task spawn so a re-entrant
61
61
  * [onStartCommand] for the same wake doesn't double-start the JS
62
62
  * handler. Reset is implicit — service destruction reclaims this
63
63
  * field along with the rest of the instance.
@@ -8,7 +8,9 @@ package com.margelo.nitro.queueplayer
8
8
  * call through the interface; consumers never see which backend
9
9
  * is active.
10
10
  *
11
- * Threading: methods are called from the service main looper.
11
+ * Threading: see the implementations attach / detach arrive on the
12
+ * service main looper and on the Equalizer-constructing thread, the
13
+ * mutators on the `Promise.async` pool, the getters on the JS thread.
12
14
  */
13
15
  interface IEqualizerEngine {
14
16
 
@@ -43,9 +43,13 @@ class LookaheadCache(
43
43
  evictionPolicy: EvictionPolicy = EvictionPolicy.LRU
44
44
  ) {
45
45
 
46
- /** Resolved on-disk cache directory: `context.cacheDir/<cacheDirName>`. */
46
+ /**
47
+ * Resolved on-disk cache directory: `context.cacheDir/<cacheDirName>`.
48
+ * Resolved through the application context, as the shared-instance key is,
49
+ * so a wrapper context with its own `cacheDir` cannot split the two.
50
+ */
47
51
  @VisibleForTesting
48
- val cacheDir: File = File(context.cacheDir, cacheDirName)
52
+ val cacheDir: File = File(context.applicationContext.cacheDir, cacheDirName)
49
53
 
50
54
  private val shared: SharedCacheInstance =
51
55
  sharedInstanceFor(context.applicationContext, cacheDir, maxSizeBytes, evictionPolicy)
@@ -58,7 +62,28 @@ class LookaheadCache(
58
62
  @VisibleForTesting
59
63
  internal val simpleCache: SimpleCache = shared.cache
60
64
 
61
- private val databaseProvider: StandaloneDatabaseProvider = shared.databaseProvider
65
+ private val protectingEvictor: FifoCacheEvictor? = shared.protectingEvictor
66
+
67
+ /**
68
+ * Name the urls eviction must not take — the playing track and its
69
+ * neighbours. Eviction runs on every span commit, so without this the entry
70
+ * backing the playing track can be deleted while it plays.
71
+ *
72
+ * A no-op under the LRU policy, whose evictor has no protected-key concept.
73
+ * LRU is not an equivalent safeguard — it bumps the recency of the span just
74
+ * read, not the whole resource — so on Android the playing track is only
75
+ * genuinely protected under FIFO. iOS protects under both policies.
76
+ */
77
+ fun setProtectedUrls(urls: Set<String>) {
78
+ protectingEvictor?.setProtectedKeys(urls)
79
+ }
80
+
81
+ /**
82
+ * Whether this cache's evictor honours [setProtectedUrls]. False under LRU,
83
+ * letting callers skip building a set that would be discarded.
84
+ */
85
+ val honoursProtection: Boolean
86
+ get() = protectingEvictor != null
62
87
 
63
88
  /**
64
89
  * Per-URL MIME store populated by the prefetch path's
@@ -89,8 +114,32 @@ class LookaheadCache(
89
114
  * transparently fall through to a re-download instead of
90
115
  * surfacing a load error.
91
116
  */
92
- fun wrapDataSourceFactory(upstream: DataSource.Factory): DataSource.Factory =
93
- cacheDataSourceFactory(upstream)
117
+ fun wrapDataSourceFactory(upstream: DataSource.Factory): DataSource.Factory {
118
+ // Read-only for playback. A write here would commit whatever the origin
119
+ // answered with — a server's error envelope included — as a track, and the
120
+ // body check that refuses one belongs on the prefetch path, where a false
121
+ // refusal costs a prefetch instead of the track. `null` sets Media3's
122
+ // `cacheIsReadOnly`, which is tested before the default-sink fallback.
123
+ // So playing a track does not cache it and does not complete a partial
124
+ // prefetch of it; only the prefetcher writes. Matches iOS, whose
125
+ // read-through server also only reads.
126
+ val cached = cacheDataSourceFactory(upstream).setCacheWriteDataSinkFactory(null)
127
+ // Decided per data source, which Media3 creates per item load, so a
128
+ // change to `servesPlayback` reaches the next item without rebuilding
129
+ // the player; the item already loading keeps the source it opened.
130
+ return DataSource.Factory {
131
+ if (servesPlayback) cached.createDataSource() else upstream.createDataSource()
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Whether the player's reads go through this cache. False while the
137
+ * consumer has the cache disabled: every read then goes to the origin and
138
+ * nothing is written, whatever the disk already holds. Written on main by
139
+ * `TrackPlayer`, read on Media3's loading thread when an item opens.
140
+ */
141
+ @Volatile
142
+ var servesPlayback: Boolean = true
94
143
 
95
144
  /**
96
145
  * Build a [CacheDataSource.Factory] rooted at [simpleCache] +
@@ -121,8 +170,13 @@ class LookaheadCache(
121
170
  // helping" signal. Log the reason so a "why is this not
122
171
  // cached" investigation has a trail.
123
172
  .setEventListener(object : CacheDataSource.EventListener {
124
- // Required by EventListener; no per-byte progress surfaced today.
125
- override fun onCachedBytesRead(cacheSizeBytes: Long, cachedBytesRead: Long) { }
173
+ override fun onCachedBytesRead(cacheSizeBytes: Long, cachedBytesRead: Long) {
174
+ // Accumulated rather than logged per read: this fires per buffer, so
175
+ // logging here would flood. The running total is what answers
176
+ // "is playback actually being served from the cache", which cannot
177
+ // be told apart from a network read by listening.
178
+ cachedBytesReadTotal.addAndGet(cachedBytesRead)
179
+ }
126
180
  override fun onCacheIgnored(reason: Int) {
127
181
  val name = when (reason) {
128
182
  CacheDataSource.CACHE_IGNORED_REASON_ERROR -> "ERROR"
@@ -137,7 +191,13 @@ class LookaheadCache(
137
191
  })
138
192
 
139
193
  /**
140
- * Drop per-instance state. The [SimpleCache] + [databaseProvider]
194
+ * Total bytes served to the player from cache rather than upstream.
195
+ * Monotonic for the cache's lifetime; read as a delta across an interval.
196
+ */
197
+ val cachedBytesReadTotal = java.util.concurrent.atomic.AtomicLong(0)
198
+
199
+ /**
200
+ * Drop per-instance state. The [SimpleCache] + database provider
141
201
  * stay alive (process-singleton, see class doc) — releasing them
142
202
  * would race Media3's `lockedFolders` static registry on the next
143
203
  * [LookaheadCache] construction targeting the same directory.
@@ -173,18 +233,17 @@ class LookaheadCache(
173
233
  // --- Status surface ---
174
234
 
175
235
  /**
176
- * Sum of cached bytes across every key in the cache. Walks
177
- * `simpleCache.keys` + `getCachedBytes` per key. O(N) on the
178
- * key count + cheap span lookup. Used by
179
- * [TrackPlayer.getLookaheadCacheStatus] for the
180
- * `currentSizeMb` field.
236
+ * Bytes currently held on disk. Reads the total Media3 already maintains, so
237
+ * it is O(1) and needs no lock beyond the cache's own.
238
+ *
239
+ * Also more accurate than summing per-key cached bytes, which reports zero
240
+ * for any resource whose content length has not been recorded yet — a
241
+ * chunked download in flight occupies disk but has no length until it
242
+ * completes. Used by [TrackPlayer.getLookaheadCacheStatus] for `currentSizeMb`.
181
243
  */
182
- fun currentSizeBytes(): Long {
183
- return simpleCache.keys.sumOf { key ->
184
- val length = contentLength(key) ?: return@sumOf 0L
185
- simpleCache.getCachedBytes(key, 0, length)
186
- }
187
- }
244
+ // Every SimpleCache method rejects a released cache, and this is read off the
245
+ // looper that releases it. A cache that has gone away holds nothing.
246
+ fun currentSizeBytes(): Long = runCatching { simpleCache.cacheSpace }.getOrDefault(0L)
188
247
 
189
248
  /**
190
249
  * Whether the cache holds the entire content for `key`. True
@@ -210,12 +269,12 @@ class LookaheadCache(
210
269
  * field — the count reflects the cache itself, not the intersection
211
270
  * with `self.tracks`.
212
271
  */
213
- fun fullyCachedCount(): Int {
214
- return simpleCache.keys.count { key ->
272
+ fun fullyCachedCount(): Int = runCatching {
273
+ simpleCache.keys.count { key ->
215
274
  val length = contentLength(key) ?: return@count false
216
275
  simpleCache.getCachedBytes(key, 0, length) >= length
217
276
  }
218
- }
277
+ }.getOrDefault(0)
219
278
 
220
279
  /**
221
280
  * Wipe every cached entry + free the on-disk space. Used by
@@ -224,9 +283,14 @@ class LookaheadCache(
224
283
  * handles the file deletion + index update internally.
225
284
  */
226
285
  fun clear() {
227
- val keys = simpleCache.keys.toList()
286
+ // Every SimpleCache method rejects a released cache, the key walk included.
287
+ // A policy change releases the shared instance out from under an in-flight
288
+ // wipe, and this now runs off the caller's thread, so an escaping throw
289
+ // reaches the default handler rather than the JS call that asked for it.
290
+ // Nothing is left to clear in that case — the release took the files with it.
291
+ val keys = runCatching { simpleCache.keys.toList() }.getOrElse { return }
228
292
  for (key in keys) {
229
- simpleCache.removeResource(key)
293
+ runCatching { simpleCache.removeResource(key) }.getOrElse { return }
230
294
  }
231
295
  mimeStore.clear()
232
296
  }
@@ -312,11 +376,20 @@ class LookaheadCache(
312
376
  EvictionPolicy.FIFO -> FifoCacheEvictor(maxSizeBytes)
313
377
  }
314
378
  val cache = SimpleCache(cacheDir, evictor, db)
315
- val newInstance = SharedCacheInstance(cache, db)
379
+ val newInstance = SharedCacheInstance(cache, db, evictor as? FifoCacheEvictor, evictionPolicy)
316
380
  sharedInstances[key] = newInstance
317
381
  return newInstance
318
382
  }
319
383
 
384
+ /**
385
+ * The eviction policy the live cache for [cacheDir] was built with, or null
386
+ * when this process holds no cache there. Media3 fixes the evictor at
387
+ * construction, so the instance is the record of what was applied.
388
+ */
389
+ @JvmStatic
390
+ internal fun appliedEvictionPolicy(cacheDir: File): EvictionPolicy? =
391
+ sharedInstances[cacheDir.absolutePath]?.evictionPolicy
392
+
320
393
  /**
321
394
  * Release + forget the shared SimpleCache for [cacheDir] so the next
322
395
  * [LookaheadCache] construction builds a fresh one. Used when the eviction
@@ -326,18 +399,27 @@ class LookaheadCache(
326
399
  */
327
400
  @JvmStatic
328
401
  @Synchronized
329
- internal fun releaseSharedInstance(cacheDir: File) {
330
- sharedInstances.remove(cacheDir.absolutePath)?.cache?.release()
402
+ internal fun releaseSharedInstance(cacheDir: File): StandaloneDatabaseProvider? {
403
+ val instance = sharedInstances.remove(cacheDir.absolutePath) ?: return null
404
+ instance.cache.release()
405
+ // Handed back so the caller can drop the cache's index tables along with
406
+ // its files. Index storage is keyed by the cache uid, so deleting the
407
+ // directory alone strands one table per policy change.
408
+ return instance.databaseProvider
331
409
  }
332
410
  }
333
411
 
334
412
  /**
335
413
  * Pair of process-singleton SimpleCache + DatabaseProvider for a
336
414
  * single cache folder. Held by the companion object's
337
- * [sharedInstances] map; never released within the process lifetime.
415
+ * [sharedInstances] map until [releaseSharedInstance] drops it for a
416
+ * policy change; a destroy leaves it in place.
338
417
  */
339
418
  private data class SharedCacheInstance(
340
419
  val cache: SimpleCache,
341
- val databaseProvider: StandaloneDatabaseProvider
420
+ val databaseProvider: StandaloneDatabaseProvider,
421
+ /** Null under LRU, which has no protected-key concept. */
422
+ val protectingEvictor: FifoCacheEvictor?,
423
+ val evictionPolicy: EvictionPolicy,
342
424
  )
343
425
  }
@@ -9,6 +9,7 @@ import androidx.media3.datasource.DataSource
9
9
  import androidx.media3.datasource.DataSpec
10
10
  import androidx.media3.datasource.cache.CacheDataSource
11
11
  import androidx.media3.datasource.cache.CacheWriter
12
+ import java.io.IOException
12
13
  import kotlinx.coroutines.CancellationException
13
14
  import kotlinx.coroutines.CoroutineScope
14
15
  import kotlinx.coroutines.Deferred
@@ -16,7 +17,10 @@ import kotlinx.coroutines.Dispatchers
16
17
  import kotlinx.coroutines.Job
17
18
  import kotlinx.coroutines.SupervisorJob
18
19
  import kotlinx.coroutines.async
20
+ import kotlinx.coroutines.awaitCancellation
19
21
  import kotlinx.coroutines.cancel
22
+ import kotlinx.coroutines.ensureActive
23
+ import kotlinx.coroutines.isActive
20
24
  import kotlinx.coroutines.launch
21
25
  import kotlinx.coroutines.withContext
22
26
 
@@ -48,10 +52,10 @@ import kotlinx.coroutines.withContext
48
52
  * flaky-network resilience:
49
53
  *
50
54
  * - Per-track download failure (IOException, HTTP error, transient
51
- * network drop): swallowed via [logSwallowed]; the orchestrator
52
- * continues to the next pending target. The failed URL is NOT
53
- * marked as cached or in-flight, so it remains eligible for
54
- * re-attempt on a subsequent reschedule.
55
+ * network drop): caught in the orchestrator loop and logged at
56
+ * `Log.w`; the orchestrator continues to the next pending target.
57
+ * The failed URL is NOT marked as cached or in-flight, so it
58
+ * remains eligible for re-attempt on a subsequent reschedule.
55
59
  * - Complete network outage (every target throws): orchestrator
56
60
  * drains pendingTargets cleanly, leaves [inFlightUrl] null, and
57
61
  * exits. The next [reschedule] call re-enqueues the still-desired
@@ -75,9 +79,21 @@ import kotlinx.coroutines.withContext
75
79
  class LookaheadCacheWriter @VisibleForTesting internal constructor(
76
80
  @get:VisibleForTesting internal val downloader: suspend (TrackItem) -> Unit,
77
81
  @get:VisibleForTesting internal val scope: CoroutineScope,
78
- @get:VisibleForTesting internal val isFullyCached: (String) -> Boolean = { false }
82
+ @get:VisibleForTesting internal val isFullyCached: (String) -> Boolean = { false },
83
+ /** True when the active network is cellular. Consulted only while
84
+ * [allowsCellularAccess] is false. */
85
+ internal val isOnCellular: () -> Boolean = { false }
79
86
  ) {
80
87
 
88
+ /**
89
+ * Whether prefetch may run over cellular. False empties the desired
90
+ * window while the active network is cellular, so nothing is
91
+ * enqueued and an in-flight download is cancelled on the next
92
+ * [reschedule]. Set from `setLookaheadCache`.
93
+ */
94
+ @Volatile
95
+ internal var allowsCellularAccess: Boolean = true
96
+
81
97
  /**
82
98
  * Optional callback fired after each per-track download iteration
83
99
  * completes (success, failure, or cancellation handled). Used by
@@ -182,6 +198,14 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
182
198
  val desired = mutableListOf<TrackItem>()
183
199
  desired.addAll(computeTargets(tracks, currentIndex, lookaheadCount))
184
200
  lookbackTarget(tracks, currentIndex)?.let { desired.add(it) }
201
+ // Prefetch downloads whole upcoming tracks, so on a metered connection it
202
+ // can spend a lot of data on tracks the listener skips past. When it is not
203
+ // permitted nothing is desired, and the reconciliation below prunes what is
204
+ // pending and cancels what is in flight. The next reschedule refills it —
205
+ // a track advance, a skip, a queue change, or the flag going back on.
206
+ if (!allowsCellularAccess && isOnCellular()) {
207
+ desired.clear()
208
+ }
185
209
  val desiredUrls = desired.map { it.url }.toSet()
186
210
 
187
211
  val toCancel: Job?
@@ -225,6 +249,8 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
225
249
 
226
250
  // Cancel outside the lock — Job.cancel() is fast but can run
227
251
  // listeners on this thread.
252
+ // Cancelling the job is what stops the blocking write too: the download
253
+ // body relays its coroutine's cancellation onto its own `CacheWriter`.
228
254
  toCancel?.cancel()
229
255
 
230
256
  if (needsOrchestrator) {
@@ -266,10 +292,9 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
266
292
  }
267
293
 
268
294
  /**
269
- * Cancel + tear down the coroutine scope. Call from
270
- * [TrackPlayer.destroyInternal] before the [LookaheadCache] is
271
- * released once the cache is gone, an in-flight CacheWriter
272
- * reading from `cache.simpleCache` would NPE.
295
+ * Cancel + tear down the coroutine scope. Called before
296
+ * `LookaheadCache.release()` clears the MIME store, so a prefetch
297
+ * still in flight cannot record a MIME type after the clear.
273
298
  */
274
299
  fun release() {
275
300
  requireMainThread()
@@ -278,11 +303,12 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
278
303
  }
279
304
 
280
305
  /**
281
- * Reads/writes of `inFlightUrl`, `inFlightJob`, `orchestratorJob`,
282
- * and `pendingTargets` happen exclusively on main; this trap turns
283
- * a future off-main caller into a debug-build crash so the bug is
284
- * visible at the call site instead of as a silent race on the
285
- * cache state.
306
+ * The public entry points (reschedule / cancel / release) are main-only; this
307
+ * trap turns a future off-main caller into a crash at the call site
308
+ * so the mistake is visible there. The fields themselves
309
+ * `inFlightUrl`, `inFlightJob`, `orchestratorJob`, `pendingTargets`
310
+ * are also written from the IO orchestrator coroutine, so they are
311
+ * guarded by `stateLock`, not by thread affinity.
286
312
  *
287
313
  * `runCatching` swallows the "Method myLooper in android.os.Looper
288
314
  * not mocked" exception that fires on a JVM host without the Android
@@ -313,7 +339,11 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
313
339
  if (lookaheadCount <= 0 || tracks.isEmpty()) return emptyList()
314
340
  val start = (currentIndex + 1).coerceAtLeast(0)
315
341
  if (start >= tracks.size) return emptyList()
316
- val end = (start + lookaheadCount).coerceAtMost(tracks.size)
342
+ // `start + lookaheadCount` overflows to a negative for a very large count,
343
+ // which coerceAtMost then keeps and subList rejects. The count reaches here
344
+ // from a JS Double, so Int.MAX_VALUE is one `Infinity` away.
345
+ val remaining = tracks.size - start
346
+ val end = start + lookaheadCount.coerceAtMost(remaining)
317
347
  return tracks.subList(start, end).filter { isRemote(it.url) }
318
348
  }
319
349
 
@@ -475,7 +505,8 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
475
505
  */
476
506
  fun create(
477
507
  cache: LookaheadCache,
478
- httpFactory: DataSource.Factory
508
+ httpFactory: DataSource.Factory,
509
+ isOnCellular: () -> Boolean
479
510
  ): LookaheadCacheWriter {
480
511
  val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
481
512
  // Wrap the upstream HTTP factory so each prefetch download
@@ -485,34 +516,35 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
485
516
  // window where we'd want to learn the format. Mirrors iOS's
486
517
  // file-extension persistence on the cached file.
487
518
  val mimeCapturingFactory = MimeCapturingDataSourceFactory(
488
- upstream = httpFactory,
519
+ upstream = MediaValidatingDataSourceFactory(httpFactory),
489
520
  onMime = { uri, contentType -> cache.recordMimeType(uri, contentType) }
490
521
  )
491
522
  val cacheDataSourceFactory = cache.cacheDataSourceFactory(mimeCapturingFactory)
492
523
  return LookaheadCacheWriter(
493
524
  downloader = { track -> downloadViaCacheWriter(track, cacheDataSourceFactory) },
494
525
  scope = scope,
495
- isFullyCached = cache::isFullyCached
526
+ isFullyCached = cache::isFullyCached,
527
+ isOnCellular = isOnCellular
496
528
  )
497
529
  }
498
530
 
499
531
  /**
500
- * Per-track download body. Builds a fresh [CacheDataSource] from
501
- * the shared factory, runs the synchronous `CacheWriter.cache()`
502
- * on `Dispatchers.IO`. Coroutine cancellation propagates via the
503
- * explicit `CancellationException` catch `writer.cancel()`
504
- * sets the volatile cancel flag that the cache loop polls
505
- * between buffer iterations, then we rethrow so the parent
506
- * launch's `for (track in targets)` loop terminates.
532
+ * Per-track download body. Builds a fresh [CacheDataSource] from the
533
+ * shared factory and runs the synchronous `CacheWriter.cache()` on
534
+ * `Dispatchers.IO`. `cache()` blocks and polls only its own cancelled
535
+ * flag, so the coroutine's cancellation is relayed onto that flag by a
536
+ * child that exists to be cancelled with it each writer is tied to
537
+ * its own job, which is what keeps a cancel from reaching the download
538
+ * the orchestrator starts next.
507
539
  */
508
540
  private suspend fun downloadViaCacheWriter(
509
541
  track: TrackItem,
510
- cacheDataSourceFactory: CacheDataSource.Factory
542
+ cacheDataSourceFactory: CacheDataSource.Factory,
511
543
  ) = withContext(Dispatchers.IO) {
512
544
  val cacheDataSource = cacheDataSourceFactory.createDataSource()
513
545
  // Cache key = source URL. The consumer-supplied `track.id` is
514
- // a metadata field that varies per app (Subsonic-style hash,
515
- // Spotify URI, app-internal counter); keying by URL keeps the
546
+ // a metadata field that varies per app (a server-side hash, a
547
+ // catalogue URI, an app-internal counter); keying by URL keeps the
516
548
  // cache generic + correct across apps + matches iOS. Same
517
549
  // URL → same cache file (collision === "already cached").
518
550
  val dataSpec = DataSpec.Builder()
@@ -520,11 +552,27 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
520
552
  .setKey(track.url)
521
553
  .build()
522
554
  val writer = CacheWriter(cacheDataSource, dataSpec, /* temporaryBuffer = */ null, /* progressListener = */ null)
555
+ // Unconfined, so the relay resumes on the thread that cancels the job
556
+ // rather than waiting for an IO worker: the workers are what a
557
+ // blocking `cache()` occupies, and a cancel that queued behind them
558
+ // would leave the download running for as long as they stay busy.
559
+ val cancelRelay = launch(Dispatchers.Unconfined) {
560
+ try {
561
+ awaitCancellation()
562
+ } finally {
563
+ writer.cancel()
564
+ }
565
+ }
523
566
  try {
567
+ ensureActive()
524
568
  writer.cache()
525
- } catch (e: CancellationException) {
526
- writer.cancel()
569
+ } catch (e: IOException) {
570
+ // A cancelled writer throws InterruptedIOException; report it as the
571
+ // cancellation it is so the orchestrator does not log a failed prefetch.
572
+ if (!isActive) throw CancellationException("prefetch cancelled").also { it.initCause(e) }
527
573
  throw e
574
+ } finally {
575
+ cancelRelay.cancel()
528
576
  }
529
577
  }
530
578
  }
@@ -25,8 +25,8 @@ import androidx.media3.extractor.DefaultExtractorsFactory
25
25
  * header override) and lines up with which names
26
26
  * [DefaultHttpDataSource.Factory.setDefaultRequestProperties] as the
27
27
  * integration point. Per-track headers would require a custom
28
- * MediaSource.Factory reading per-item metadata extras tracked as
29
- * a backlog item if Subsonic-style per-request signing ever lands.
28
+ * MediaSource.Factory reading per-item metadata extras, which this
29
+ * builder does not do.
30
30
  *
31
31
  * `file://` URIs transparently route through [DefaultDataSource]
32
32
  * without touching the HTTP factory — matches iOS's file-URL
@@ -249,8 +249,8 @@ object MediaItemBuilder {
249
249
  * remain coherent.
250
250
  *
251
251
  * Read timeout: defaults to 30000 ms (vs Media3's 8s default).
252
- * The longer timeout tolerates slow-spin-up servers (e.g. Subsonic
253
- * on spun-down disks needing ~20s to respond). Connect timeout
252
+ * The longer timeout tolerates slow-spin-up servers (a media server
253
+ * on spun-down disks can take ~20s to respond). Connect timeout
254
254
  * stays at Media3's 8s default — TCP handshake is unaffected by
255
255
  * disk spin-up.
256
256
  */
@@ -264,8 +264,8 @@ object MediaItemBuilder {
264
264
  if (merged.isNotEmpty()) {
265
265
  setDefaultRequestProperties(merged)
266
266
  }
267
- // Subsonic-behind-reverse-proxy deployments redirect
268
- // http→https (or vice versa) for auth flows; disabling
267
+ // Some servers behind a reverse proxy redirect http→https (or
268
+ // vice versa) for auth flows; disabling
269
269
  // cross-protocol redirects silently truncates downloads
270
270
  // partway through a redirect.
271
271
  setAllowCrossProtocolRedirects(true)
@@ -0,0 +1,156 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ /**
4
+ * Decides whether an HTTP response that claims to carry a track can be
5
+ * believed, from its `Content-Type` and the first bytes of its body.
6
+ *
7
+ * A server that answers an API-level failure with `200` and an XML or JSON
8
+ * envelope is the case this exists for: media servers commonly report
9
+ * errors that way by design, and a proxy answering with an HTML page does the
10
+ * same. Such a body passes every length and status check, so without this the
11
+ * prefetcher writes it into the cache as a complete track and it is served from
12
+ * disk on every later play — the track stays broken after the server is well
13
+ * again.
14
+ *
15
+ * The rules identify what is **not** media rather than demanding proof of what
16
+ * is. The two mistakes are not equal: a false rejection costs a prefetch and the
17
+ * track still plays from its origin, while a false acceptance breaks the track
18
+ * until the cache is cleared by hand.
19
+ *
20
+ * Mirrored rule for rule by `MediaResponseCheck.swift` on iOS.
21
+ */
22
+ internal object MediaResponseCheck {
23
+
24
+ /**
25
+ * Why a response was refused. Each case carries what was judged so a caller's
26
+ * message names the response rather than just the verdict.
27
+ */
28
+ sealed interface Rejection {
29
+ /** The declared type is a markup or JSON document, which no audio container is. */
30
+ data class MarkupContentType(val contentType: String) : Rejection
31
+
32
+ /**
33
+ * The declared type is text of some other kind and the body carries no
34
+ * recognised container signature. `text/plain` is not refused on the label
35
+ * alone — media servers do mislabel audio — so the body is given the chance
36
+ * to prove itself.
37
+ */
38
+ data class TextWithoutMediaSignature(val contentType: String) : Rejection
39
+
40
+ /** No usable declared type, and the body begins as a markup or JSON document. */
41
+ data object MarkupBody : Rejection
42
+ }
43
+
44
+ /** Enough of a body to carry any container signature read below. */
45
+ const val HEAD_BYTES: Int = 64
46
+
47
+ /**
48
+ * Types that are documents, never audio. Narrow on purpose:
49
+ * `application/octet-stream` and `text/plain` are missing because servers
50
+ * serve real audio under both.
51
+ */
52
+ private val MARKUP_TYPES = setOf(
53
+ "text/html", "application/xhtml+xml",
54
+ "application/xml", "text/xml",
55
+ "application/json", "text/json",
56
+ )
57
+
58
+ private val SIGNATURE_PREFIXES = listOf(
59
+ "ID3", "fLaC", "OggS", "RIFF", "FORM", "caff", "#!AMR", "#EXTM3U",
60
+ )
61
+
62
+ /**
63
+ * The verdict for a response, or null to accept it.
64
+ *
65
+ * [head] is the first bytes of the body — [HEAD_BYTES] is plenty for every
66
+ * signature below. [headIsStartOfResource] is false when the request carried a
67
+ * range, and then only the declared type is judged: a body that begins part
68
+ * way into a file says nothing about the file's first bytes.
69
+ */
70
+ fun rejection(
71
+ contentType: String?,
72
+ head: ByteArray,
73
+ headIsStartOfResource: Boolean
74
+ ): Rejection? {
75
+ val declared = normalised(contentType)
76
+ if (declared != null && declared in MARKUP_TYPES) {
77
+ return Rejection.MarkupContentType(declared)
78
+ }
79
+ if (!headIsStartOfResource) return null
80
+ val body = droppingByteOrderMark(head)
81
+ if (declared != null && declared.startsWith("text/")) {
82
+ return if (hasMediaSignature(body)) null else Rejection.TextWithoutMediaSignature(declared)
83
+ }
84
+ return if (beginsAsMarkup(body)) Rejection.MarkupBody else null
85
+ }
86
+
87
+ /**
88
+ * Lowercased type with any `; charset=…` parameter and surrounding space
89
+ * removed. Null for a missing or empty header.
90
+ */
91
+ private fun normalised(contentType: String?): String? {
92
+ if (contentType.isNullOrEmpty()) return null
93
+ return contentType.substringBefore(';').trim().lowercase().ifEmpty { null }
94
+ }
95
+
96
+ /**
97
+ * Drop a byte-order mark so the checks below read the first real byte. A
98
+ * UTF-16 mark matters twice over: `FF FE` also satisfies the MPEG frame sync
99
+ * pattern, so a UTF-16 document would otherwise read as audio.
100
+ */
101
+ private fun droppingByteOrderMark(head: ByteArray): ByteArray {
102
+ if (head.size >= 3 &&
103
+ head[0] == 0xEF.toByte() && head[1] == 0xBB.toByte() && head[2] == 0xBF.toByte()
104
+ ) {
105
+ return head.copyOfRange(3, head.size)
106
+ }
107
+ if (head.size >= 2 && (
108
+ (head[0] == 0xFF.toByte() && head[1] == 0xFE.toByte()) ||
109
+ (head[0] == 0xFE.toByte() && head[1] == 0xFF.toByte())
110
+ )
111
+ ) {
112
+ return head.copyOfRange(2, head.size)
113
+ }
114
+ return head
115
+ }
116
+
117
+ /**
118
+ * Whether the body opens with the magic bytes of a container either platform
119
+ * plays, or with an HLS playlist's own first line.
120
+ */
121
+ private fun hasMediaSignature(body: ByteArray): Boolean {
122
+ if (body.isEmpty()) return false
123
+ // MPEG audio frame sync: eleven set bits across the first two bytes. ADTS
124
+ // AAC shares it.
125
+ if (body.size >= 2 &&
126
+ body[0] == 0xFF.toByte() &&
127
+ (body[1].toInt() and 0xE0) == 0xE0
128
+ ) {
129
+ return true
130
+ }
131
+ if (SIGNATURE_PREFIXES.any { startsWith(body, it, 0) }) return true
132
+ // ISO base media (MP4 / M4A / ALAC): a box length, then the type.
133
+ if (body.size >= 8 && startsWith(body, "ftyp", 4)) return true
134
+ return false
135
+ }
136
+
137
+ /**
138
+ * Whether the body opens as an XML / HTML document or a JSON value. No
139
+ * container above begins with either character.
140
+ */
141
+ private fun beginsAsMarkup(body: ByteArray): Boolean {
142
+ val first = body.firstOrNull { !isAsciiSpace(it) } ?: return false
143
+ return first == '<'.code.toByte() || first == '{'.code.toByte()
144
+ }
145
+
146
+ private fun isAsciiSpace(b: Byte): Boolean =
147
+ b == 0x20.toByte() || b == 0x09.toByte() || b == 0x0A.toByte() || b == 0x0D.toByte()
148
+
149
+ private fun startsWith(body: ByteArray, ascii: String, at: Int): Boolean {
150
+ if (body.size < at + ascii.length) return false
151
+ for (i in ascii.indices) {
152
+ if (body[at + i] != ascii[i].code.toByte()) return false
153
+ }
154
+ return true
155
+ }
156
+ }