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
@@ -0,0 +1,691 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// Resolution of an HTTP `Range` header against a known file size.
5
+ enum MediaByteRange: Equatable {
6
+ /// No Range header, or one the server ignores (malformed / invalid
7
+ /// bounds) — serve the whole file with `200`.
8
+ case full
9
+ /// A satisfiable single range with inclusive bounds — serve `206`.
10
+ case partial(start: Int64, end: Int64)
11
+ /// A syntactically-valid range that starts past EOF — serve `416`.
12
+ case unsatisfiable
13
+ }
14
+
15
+ /// Parsed view of an incoming request: the method, the request target,
16
+ /// and the one header the file server acts on (`Range`). Built from the
17
+ /// raw header block (everything before the `\r\n\r\n` terminator).
18
+ struct MediaHTTPRequest: Equatable {
19
+ let method: String
20
+ let path: String
21
+ let rangeHeader: String?
22
+
23
+ /// The `<token>` from a `/media/<32-hex>` path, or `nil` when the path
24
+ /// is anything else. The token is both the routing key (which file) and
25
+ /// the access secret (high-entropy, unenumerable) for the LAN-reachable
26
+ /// server.
27
+ var mediaToken: String? {
28
+ let prefix = "/media/"
29
+ guard path.hasPrefix(prefix) else { return nil }
30
+ let token = String(path.dropFirst(prefix.count))
31
+ guard token.count == 32,
32
+ token.allSatisfy({ ($0 >= "0" && $0 <= "9") || ($0 >= "a" && $0 <= "f") })
33
+ else { return nil }
34
+ return token
35
+ }
36
+
37
+ init?(headerData: Data) {
38
+ guard let text = String(data: headerData, encoding: .utf8) else { return nil }
39
+ var lines = text.components(separatedBy: "\r\n")
40
+ guard !lines.isEmpty else { return nil }
41
+ let requestLine = lines.removeFirst().split(separator: " ")
42
+ guard requestLine.count >= 2 else { return nil }
43
+ self.method = String(requestLine[0]).uppercased()
44
+ // Strip any query string — the file path never carries one, and the
45
+ // token match below expects the bare path.
46
+ let target = String(requestLine[1])
47
+ self.path = target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? target
48
+
49
+ var range: String?
50
+ for line in lines where !line.isEmpty {
51
+ guard let colon = line.firstIndex(of: ":") else { continue }
52
+ let name = line[line.startIndex..<colon].trimmingCharacters(in: .whitespaces).lowercased()
53
+ if name == "range" {
54
+ range = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
55
+ }
56
+ }
57
+ self.rangeHeader = range
58
+ }
59
+ }
60
+
61
+ /// What a `/media/<token>` request resolves to.
62
+ ///
63
+ /// Two servers share this connection. The cast server registers local files it
64
+ /// was handed by the consumer; the playback cache registers remote origins and
65
+ /// lets the cache answer for them when it holds a complete copy. Keeping the
66
+ /// arbitration in the resolver is what keeps `LookaheadCache` out of this file.
67
+ enum MediaSource {
68
+ case file(URL, contentType: String)
69
+ case readThrough(ReadThroughSource)
70
+ }
71
+
72
+ /// An origin to read through, with the cache's complete copy when it has one.
73
+ struct ReadThroughSource {
74
+ let origin: URL
75
+ /// The consumer's configured headers, re-applied on the origin leg.
76
+ let headers: [String: String]
77
+ /// The cache's complete copy of `origin`, resolved at request time. Nil when
78
+ /// the cache does not hold the whole file — there is no partial serving.
79
+ let cachedFile: URL?
80
+ let session: URLSession
81
+ }
82
+
83
+ /// Reconciles a data task's body with its response heads, so that a task the
84
+ /// system re-issued on resume delivers every byte once.
85
+ ///
86
+ /// A resumed `URLSessionDataTask` is not guaranteed to continue rather than
87
+ /// re-issue the request. A second head that matches the first means the body
88
+ /// is starting over: the bytes the first transfer already delivered are
89
+ /// dropped from the restarted one. A second head that differs is a different
90
+ /// resource and cannot be stitched.
91
+ struct OriginRestartStitcher {
92
+ enum HeadVerdict: Equatable {
93
+ /// The first head, or a re-issued request answered the same way.
94
+ case accept
95
+ /// A re-issued request answered differently.
96
+ case reject
97
+ }
98
+
99
+ private var first: HTTPURLResponse?
100
+ private(set) var deliveredBytes = 0
101
+ private var skip = 0
102
+
103
+ mutating func head(_ response: HTTPURLResponse) -> HeadVerdict {
104
+ guard let first else {
105
+ self.first = response
106
+ return .accept
107
+ }
108
+ guard Self.sameAnswer(first, response) else { return .reject }
109
+ skip = deliveredBytes
110
+ return .accept
111
+ }
112
+
113
+ /// The part of `data` the client has not yet been sent; empty while a
114
+ /// restarted transfer is still repeating what was delivered.
115
+ mutating func body(_ data: Data) -> Data {
116
+ var chunk = data
117
+ if skip > 0 {
118
+ let drop = min(skip, chunk.count)
119
+ skip -= drop
120
+ chunk = chunk.dropFirst(drop)
121
+ }
122
+ deliveredBytes += chunk.count
123
+ return chunk
124
+ }
125
+
126
+ private static let identityHeaders = ["Content-Range", "Content-Length", "ETag", "Last-Modified"]
127
+
128
+ private static func sameAnswer(_ a: HTTPURLResponse, _ b: HTTPURLResponse) -> Bool {
129
+ a.statusCode == b.statusCode
130
+ && identityHeaders.allSatisfy {
131
+ a.value(forHTTPHeaderField: $0) == b.value(forHTTPHeaderField: $0)
132
+ }
133
+ }
134
+ }
135
+
136
+ /// One origin response delivered as the `Data` chunks URLSession produces.
137
+ ///
138
+ /// The relay's own buffer is what is bounded: the task is suspended while more
139
+ /// than `highWater` bytes have been yielded but not yet sent to the client and
140
+ /// resumed under `lowWater`. Suspending a task stops its callbacks, not
141
+ /// necessarily its transfer — the system may keep receiving into its own
142
+ /// buffers — and a resumed data task may re-issue the request instead of
143
+ /// continuing; `OriginRestartStitcher` keeps the client's bytes exactly once
144
+ /// either way.
145
+ private final class OriginBody: NSObject, URLSessionDataDelegate {
146
+ let chunks: AsyncThrowingStream<Data, Error>
147
+ private var yield: AsyncThrowingStream<Data, Error>.Continuation?
148
+ private let lock = NSLock()
149
+ private var received: HTTPURLResponse?
150
+ private var failure: Error?
151
+ private var waiter: CheckedContinuation<URLResponse, Error>?
152
+ private var pendingBytes = 0
153
+ private var suspended = false
154
+ private var stitcher = OriginRestartStitcher()
155
+ private static let highWater = 2 * 1024 * 1024
156
+ private static let lowWater = 512 * 1024
157
+
158
+ /// The origin answered a re-issued request differently from the first one,
159
+ /// so the bytes cannot be stitched onto what was already relayed.
160
+ struct RestartedWithADifferentAnswer: Error {}
161
+
162
+ override init() {
163
+ var continuation: AsyncThrowingStream<Data, Error>.Continuation?
164
+ chunks = AsyncThrowingStream { continuation = $0 }
165
+ super.init()
166
+ yield = continuation
167
+ }
168
+
169
+ /// The response head, once the origin has answered.
170
+ func response() async throws -> URLResponse {
171
+ try await withCheckedThrowingContinuation { cont in
172
+ lock.lock()
173
+ if let received {
174
+ lock.unlock()
175
+ cont.resume(returning: received)
176
+ } else if let failure {
177
+ lock.unlock()
178
+ cont.resume(throwing: failure)
179
+ } else {
180
+ waiter = cont
181
+ lock.unlock()
182
+ }
183
+ }
184
+ }
185
+
186
+ /// The relay has sent `count` bytes on; resume the origin when enough has
187
+ /// drained. The task call stays under the lock so a suspend decided on the
188
+ /// delegate queue and a resume decided here cannot land in the wrong order.
189
+ func consumed(_ count: Int, task: URLSessionDataTask) {
190
+ lock.lock()
191
+ defer { lock.unlock() }
192
+ pendingBytes -= count
193
+ if suspended, pendingBytes < Self.lowWater {
194
+ suspended = false
195
+ task.resume()
196
+ }
197
+ }
198
+
199
+ func urlSession(
200
+ _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
201
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
202
+ ) {
203
+ lock.lock()
204
+ guard let http = response as? HTTPURLResponse else {
205
+ lock.unlock()
206
+ completionHandler(.cancel)
207
+ return
208
+ }
209
+ guard stitcher.head(http) == .accept else {
210
+ lock.unlock()
211
+ completionHandler(.cancel)
212
+ yield?.finish(throwing: RestartedWithADifferentAnswer())
213
+ return
214
+ }
215
+ let waiter: CheckedContinuation<URLResponse, Error>?
216
+ if received == nil {
217
+ received = http
218
+ waiter = self.waiter
219
+ self.waiter = nil
220
+ } else {
221
+ waiter = nil
222
+ }
223
+ lock.unlock()
224
+ waiter?.resume(returning: http)
225
+ completionHandler(.allow)
226
+ }
227
+
228
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
229
+ lock.lock()
230
+ let chunk = stitcher.body(data)
231
+ guard !chunk.isEmpty else {
232
+ lock.unlock()
233
+ return
234
+ }
235
+ pendingBytes += chunk.count
236
+ if !suspended, pendingBytes > Self.highWater {
237
+ suspended = true
238
+ dataTask.suspend()
239
+ }
240
+ lock.unlock()
241
+ yield?.yield(chunk)
242
+ }
243
+
244
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
245
+ lock.lock()
246
+ let waiter = self.waiter
247
+ self.waiter = nil
248
+ if let error { failure = error }
249
+ lock.unlock()
250
+ if let error {
251
+ waiter?.resume(throwing: error)
252
+ yield?.finish(throwing: error)
253
+ } else {
254
+ yield?.finish()
255
+ }
256
+ }
257
+ }
258
+
259
+ /// Serves one resolved `MediaSource` over a single `NWConnection` as HTTP/1.1:
260
+ /// `GET`/`HEAD` on `/media/<token>` with `Range` support (the Cast
261
+ /// receiver byte-range probes + seeks). One responder per accepted
262
+ /// connection; it owns the connection, runs all I/O on a dedicated serial
263
+ /// queue, streams the body in bounded chunks (no whole-file buffering),
264
+ /// and reports completion via `onFinished` so the owner can drop it.
265
+ final class MediaHTTPConnection {
266
+
267
+ private let connection: NWConnection
268
+ private let resolve: (String) -> MediaSource?
269
+ private let onFinished: () -> Void
270
+ private let queue: DispatchQueue
271
+ /// In-flight body-streaming task, cancelled when the client hangs up so
272
+ /// we stop reading a range the receiver has abandoned.
273
+ private var streamTask: Task<Void, Never>?
274
+ private let stateLock = NSLock()
275
+ private var finished = false
276
+
277
+ /// Bound on the accumulated request header so a stalled/malicious client
278
+ /// can't grow the buffer without limit.
279
+ private static let maxHeaderBytes = 64 * 1024
280
+ /// Body streaming chunk — keeps memory flat for large offline files.
281
+ private static let chunkBytes = 256 * 1024
282
+ private static let headerTerminator = Data("\r\n\r\n".utf8)
283
+
284
+ init(
285
+ connection: NWConnection,
286
+ resolve: @escaping (String) -> MediaSource?,
287
+ onFinished: @escaping () -> Void
288
+ ) {
289
+ self.connection = connection
290
+ self.resolve = resolve
291
+ self.onFinished = onFinished
292
+ self.queue = DispatchQueue(label: "queueplayer.cast.mediaserver.conn")
293
+ }
294
+
295
+ func start() {
296
+ connection.stateUpdateHandler = { [weak self] state in
297
+ guard let self = self else { return }
298
+ switch state {
299
+ case .ready:
300
+ self.readRequest(buffer: Data())
301
+ case .failed(let error):
302
+ // A receiver cancelling a range connection it no longer needs
303
+ // arrives here as a reset — expected churn, not an error.
304
+ if !Self.isBenignDisconnect(error) {
305
+ NSLog("[LocalMediaServer] connection failed: \(error)")
306
+ }
307
+ self.cancel()
308
+ case .cancelled:
309
+ self.finish()
310
+ default:
311
+ break
312
+ }
313
+ }
314
+ connection.start(queue: queue)
315
+ }
316
+
317
+ /// Cancel the connection + any in-flight streaming. Idempotent.
318
+ func cancel() {
319
+ stateLock.lock()
320
+ let task = streamTask
321
+ streamTask = nil
322
+ stateLock.unlock()
323
+ task?.cancel()
324
+ connection.cancel()
325
+ }
326
+
327
+ // MARK: - Request
328
+
329
+ private func readRequest(buffer: Data) {
330
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) {
331
+ [weak self] data, _, isComplete, error in
332
+ guard let self = self else { return }
333
+ if error != nil {
334
+ self.cancel()
335
+ return
336
+ }
337
+ var buf = buffer
338
+ if let data = data { buf.append(data) }
339
+ // Bound the header accumulation right after the append so a client
340
+ // that never sends `\r\n\r\n` can't grow the buffer past the cap.
341
+ if buf.count > Self.maxHeaderBytes {
342
+ self.respondError(status: 431, reason: "Request Header Fields Too Large")
343
+ return
344
+ }
345
+ guard let terminator = buf.range(of: Self.headerTerminator) else {
346
+ if isComplete {
347
+ self.cancel()
348
+ } else {
349
+ self.readRequest(buffer: buf)
350
+ }
351
+ return
352
+ }
353
+ let headerData = buf.subdata(in: buf.startIndex..<terminator.lowerBound)
354
+ guard let request = MediaHTTPRequest(headerData: headerData) else {
355
+ self.respondError(status: 400, reason: "Bad Request")
356
+ return
357
+ }
358
+ self.serve(request)
359
+ }
360
+ }
361
+
362
+ private func serve(_ request: MediaHTTPRequest) {
363
+ guard request.method == "GET" || request.method == "HEAD" else {
364
+ respondError(status: 405, reason: "Method Not Allowed")
365
+ return
366
+ }
367
+ guard let token = request.mediaToken, let source = resolve(token) else {
368
+ respondError(status: 404, reason: "Not Found")
369
+ return
370
+ }
371
+ switch source {
372
+ case .file(let url, let contentType):
373
+ guard let handle = Self.openForReading(url) else {
374
+ Self.logCache("404 no-file", request: request, detail: url.lastPathComponent)
375
+ respondError(status: 404, reason: "Not Found")
376
+ return
377
+ }
378
+ Self.logCache("DISK", request: request, detail: "\(handle.size)B \(url.lastPathComponent)")
379
+ serveFile(handle, contentType: contentType, request: request)
380
+ case .readThrough(let src):
381
+ // The cache answers only when it holds the whole file AND the file opens
382
+ // right now — anything else reads through. Opening before committing to a
383
+ // response is what makes that fall-through real: once the head is sent
384
+ // there is no going back to the origin.
385
+ if let cached = src.cachedFile, let handle = Self.openForReading(cached) {
386
+ Self.logCache(
387
+ "HIT", request: request,
388
+ detail: "\(handle.size)B from \(cached.lastPathComponent)")
389
+ serveFile(handle, contentType: MimeTypes.fromFile(cached), request: request)
390
+ } else {
391
+ Self.logCache(
392
+ "MISS", request: request,
393
+ detail: src.cachedFile == nil ? "not cached" : "cache file unopenable")
394
+ readThrough(src, request: request)
395
+ }
396
+ }
397
+ }
398
+
399
+ /// Open a regular file and measure it, or nil if it is missing, a directory,
400
+ /// or unreadable. One syscall path for both the stat and the open, so the
401
+ /// file cannot vanish between deciding to serve it and reading it.
402
+ private static func openForReading(_ url: URL) -> (handle: FileHandle, size: Int64)? {
403
+ var isDir: ObjCBool = false
404
+ guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), !isDir.boolValue,
405
+ let handle = try? FileHandle(forReadingFrom: url),
406
+ let size = try? handle.seekToEnd()
407
+ else { return nil }
408
+ try? handle.seek(toOffset: 0)
409
+ return (handle, Int64(size))
410
+ }
411
+
412
+ private func serveFile(
413
+ _ opened: (handle: FileHandle, size: Int64),
414
+ contentType: String,
415
+ request: MediaHTTPRequest
416
+ ) {
417
+ let size = opened.size
418
+ let isHead = request.method == "HEAD"
419
+ switch Self.resolveRange(request.rangeHeader, fileSize: size) {
420
+ case .full:
421
+ let head = Self.headFull(contentType: contentType, fileSize: size)
422
+ sendBodyAfterHead(head, handle: opened.handle, start: 0, end: max(0, size - 1), include: size > 0 && !isHead)
423
+ case .partial(let start, let end):
424
+ let head = Self.headPartial(contentType: contentType, start: start, end: end, fileSize: size)
425
+ sendBodyAfterHead(head, handle: opened.handle, start: start, end: end, include: !isHead)
426
+ case .unsatisfiable:
427
+ try? opened.handle.close()
428
+ sendHeadOnly(Self.headUnsatisfiable(fileSize: size))
429
+ }
430
+ }
431
+
432
+ // MARK: - Response
433
+
434
+ private func sendHeadOnly(_ head: String) {
435
+ connection.send(content: Data(head.utf8), completion: .contentProcessed { [weak self] _ in
436
+ self?.cancel()
437
+ })
438
+ }
439
+
440
+ /// Send the header block, then (when `include`) stream the inclusive
441
+ /// `[start, end]` byte range from `handle` in bounded chunks. The caller
442
+ /// passes `include: false` for a `HEAD` or an empty file, which sends the
443
+ /// head and closes.
444
+ private func sendBodyAfterHead(_ head: String, handle: FileHandle, start: Int64, end: Int64, include: Bool) {
445
+ let task = Task { [weak self] in
446
+ guard let self = self else { try? handle.close(); return }
447
+ defer { try? handle.close() }
448
+ do {
449
+ try await self.send(Data(head.utf8))
450
+ if include {
451
+ try await self.streamFile(handle, start: start, end: end)
452
+ }
453
+ self.cancel()
454
+ } catch {
455
+ // A client disconnect mid-stream surfaces as a send/read failure —
456
+ // normal churn for cancelled range requests, just clean up. A file
457
+ // unlinked mid-response keeps reading: the handle holds the inode.
458
+ self.cancel()
459
+ }
460
+ }
461
+ stateLock.lock()
462
+ streamTask = task
463
+ stateLock.unlock()
464
+ }
465
+
466
+ private func streamFile(_ handle: FileHandle, start: Int64, end: Int64) async throws {
467
+ try handle.seek(toOffset: UInt64(start))
468
+ var remaining = end - start + 1
469
+ while remaining > 0 {
470
+ if Task.isCancelled { return }
471
+ let want = Int(min(Int64(Self.chunkBytes), remaining))
472
+ guard let chunk = try handle.read(upToCount: want), !chunk.isEmpty else { return }
473
+ try await send(chunk)
474
+ remaining -= Int64(chunk.count)
475
+ }
476
+ }
477
+
478
+ /// Relay the origin, applying the consumer's headers and forwarding the
479
+ /// client's `Range` untouched.
480
+ ///
481
+ /// The origin's status line is relayed as-is, so an HTTP error stays an HTTP
482
+ /// error and the player classifies it as one. An origin leg that fails before
483
+ /// producing any response closes the connection without a reply, which
484
+ /// AVFoundation reports as a transport failure rather than a server one —
485
+ /// the distinction the retry classifier depends on.
486
+ private func readThrough(_ src: ReadThroughSource, request: MediaHTTPRequest) {
487
+ let task = Task { [weak self] in
488
+ guard let self = self else { return }
489
+ do {
490
+ var upstream = URLRequest(url: src.origin)
491
+ upstream.httpMethod = request.method
492
+ for (name, value) in src.headers {
493
+ upstream.setValue(value, forHTTPHeaderField: name)
494
+ }
495
+ if let range = request.rangeHeader {
496
+ upstream.setValue(range, forHTTPHeaderField: "Range")
497
+ }
498
+ // Identity, always. `URLSession` decodes transparently, so a compressed
499
+ // origin would leave the cache holding decoded bytes while a `Range`
500
+ // addresses the compressed ones — two different byte spaces for one
501
+ // resource, and a cache hit and a miss would disagree about every offset.
502
+ upstream.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
503
+
504
+ // The body arrives as the `Data` chunks URLSession delivers, not one
505
+ // byte per iteration: this is the playback path when the cache misses.
506
+ let body = OriginBody()
507
+ let dataTask = src.session.dataTask(with: upstream)
508
+ dataTask.delegate = body
509
+ dataTask.resume()
510
+ defer { dataTask.cancel() }
511
+ // The head wait is a plain continuation, so a client hanging up while
512
+ // the origin is still answering has to reach the task through the
513
+ // cancellation handler; the cancel completes the task with an error,
514
+ // which resumes the waiter.
515
+ let response = try await withTaskCancellationHandler {
516
+ try await body.response()
517
+ } onCancel: {
518
+ dataTask.cancel()
519
+ }
520
+ guard let http = response as? HTTPURLResponse else {
521
+ self.cancel()
522
+ return
523
+ }
524
+ Self.logCache(
525
+ "ORIGIN \(http.statusCode)", request: request,
526
+ detail: "len=\(http.expectedContentLength) "
527
+ + "range=\(http.value(forHTTPHeaderField: "Content-Range") ?? "-")")
528
+ try await self.send(Data(Self.relayHead(http).utf8))
529
+ if request.method != "HEAD" {
530
+ for try await chunk in body.chunks {
531
+ if Task.isCancelled { break }
532
+ try await self.send(chunk)
533
+ body.consumed(chunk.count, task: dataTask)
534
+ }
535
+ }
536
+ self.cancel()
537
+ } catch {
538
+ Self.logCache("ORIGIN-FAILED", request: request, detail: "\(error)")
539
+ self.cancel()
540
+ }
541
+ }
542
+ stateLock.lock()
543
+ streamTask = task
544
+ stateLock.unlock()
545
+ }
546
+
547
+ /// Where every response came from, so "the cache is not being used" is a
548
+ /// thing the log answers rather than a thing to infer from byte totals.
549
+ static func logCache(_ verdict: String, request: MediaHTTPRequest, detail: String) {
550
+ #if DEBUG
551
+ NSLog(
552
+ "[RNQP-CACHE] %@ %@ range=%@ token=%@ %@",
553
+ verdict, request.method, request.rangeHeader ?? "-",
554
+ String((request.mediaToken ?? "-").prefix(8)), detail)
555
+ #endif
556
+ }
557
+
558
+ /// Headers relayed from the origin to the client. Everything else is dropped:
559
+ /// the client is AVFoundation reading media, and nothing else the origin says
560
+ /// is its business.
561
+ static let relayedHeaders = ["Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"]
562
+
563
+ /// The reason phrases for the statuses an origin relays. Fixed ASCII rather
564
+ /// than `HTTPURLResponse.localizedString(forStatusCode:)`, which is
565
+ /// localised text; the phrase is optional on the wire, so an unlisted
566
+ /// status is relayed with none.
567
+ static func reasonPhrase(for status: Int) -> String {
568
+ switch status {
569
+ case 200: return "OK"
570
+ case 206: return "Partial Content"
571
+ case 304: return "Not Modified"
572
+ case 400: return "Bad Request"
573
+ case 401: return "Unauthorized"
574
+ case 403: return "Forbidden"
575
+ case 404: return "Not Found"
576
+ case 416: return "Range Not Satisfiable"
577
+ case 500: return "Internal Server Error"
578
+ case 502: return "Bad Gateway"
579
+ case 503: return "Service Unavailable"
580
+ default: return ""
581
+ }
582
+ }
583
+
584
+ static func relayHead(_ response: HTTPURLResponse) -> String {
585
+ var head = "HTTP/1.1 \(response.statusCode) "
586
+ + "\(reasonPhrase(for: response.statusCode))\r\n"
587
+ for name in relayedHeaders {
588
+ if let value = response.value(forHTTPHeaderField: name) {
589
+ head += "\(name): \(value)\r\n"
590
+ }
591
+ }
592
+ return head + "Connection: close\r\n\r\n"
593
+ }
594
+
595
+ private func send(_ data: Data) async throws {
596
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
597
+ connection.send(content: data, completion: .contentProcessed { error in
598
+ if let error = error { cont.resume(throwing: error) } else { cont.resume(returning: ()) }
599
+ })
600
+ }
601
+ }
602
+
603
+ private func respondError(status: Int, reason: String) {
604
+ sendHeadOnly(Self.headError(status: status, reason: reason))
605
+ }
606
+
607
+ private func finish() {
608
+ stateLock.lock()
609
+ if finished { stateLock.unlock(); return }
610
+ finished = true
611
+ let task = streamTask
612
+ streamTask = nil
613
+ stateLock.unlock()
614
+ task?.cancel()
615
+ onFinished()
616
+ }
617
+
618
+ // MARK: - Range resolution (pure)
619
+
620
+ /// Resolve an HTTP `Range` header against `fileSize`. Single-range only
621
+ /// (the Cast receiver never sends multi-range); a multi-range header
622
+ /// resolves to its first range. Per RFC 7233 a malformed / inverted
623
+ /// range is ignored (`.full`, i.e. `200`); a syntactically-valid range
624
+ /// starting at or past EOF is `.unsatisfiable` (`416`).
625
+ static func resolveRange(_ header: String?, fileSize: Int64) -> MediaByteRange {
626
+ guard fileSize > 0 else { return .full }
627
+ guard let raw = header?.lowercased(), raw.hasPrefix("bytes=") else { return .full }
628
+ let spec = raw.dropFirst("bytes=".count)
629
+ let firstSpec = spec.split(separator: ",").first.map(String.init) ?? ""
630
+ guard let dash = firstSpec.firstIndex(of: "-") else { return .full }
631
+ let startStr = firstSpec[firstSpec.startIndex..<dash].trimmingCharacters(in: .whitespaces)
632
+ let endStr = firstSpec[firstSpec.index(after: dash)...].trimmingCharacters(in: .whitespaces)
633
+
634
+ if startStr.isEmpty {
635
+ // Suffix form `-N`: the last N bytes.
636
+ guard let suffix = Int64(endStr), suffix > 0 else { return .unsatisfiable }
637
+ let start = max(0, fileSize - suffix)
638
+ return .partial(start: start, end: fileSize - 1)
639
+ }
640
+ guard let start = Int64(startStr), start >= 0 else { return .full }
641
+ if start >= fileSize { return .unsatisfiable }
642
+ if endStr.isEmpty {
643
+ return .partial(start: start, end: fileSize - 1)
644
+ }
645
+ guard let end = Int64(endStr) else { return .full }
646
+ if end < start { return .full }
647
+ return .partial(start: start, end: min(end, fileSize - 1))
648
+ }
649
+
650
+ // MARK: - Header builders (pure)
651
+
652
+ static func headFull(contentType: String, fileSize: Int64) -> String {
653
+ "HTTP/1.1 200 OK\r\n"
654
+ + "Content-Type: \(contentType)\r\n"
655
+ + "Content-Length: \(fileSize)\r\n"
656
+ + "Accept-Ranges: bytes\r\n"
657
+ + "Connection: close\r\n\r\n"
658
+ }
659
+
660
+ static func headPartial(contentType: String, start: Int64, end: Int64, fileSize: Int64) -> String {
661
+ "HTTP/1.1 206 Partial Content\r\n"
662
+ + "Content-Type: \(contentType)\r\n"
663
+ + "Content-Range: bytes \(start)-\(end)/\(fileSize)\r\n"
664
+ + "Content-Length: \(end - start + 1)\r\n"
665
+ + "Accept-Ranges: bytes\r\n"
666
+ + "Connection: close\r\n\r\n"
667
+ }
668
+
669
+ static func headUnsatisfiable(fileSize: Int64) -> String {
670
+ "HTTP/1.1 416 Range Not Satisfiable\r\n"
671
+ + "Content-Range: bytes */\(fileSize)\r\n"
672
+ + "Content-Length: 0\r\n"
673
+ + "Connection: close\r\n\r\n"
674
+ }
675
+
676
+ static func headError(status: Int, reason: String) -> String {
677
+ "HTTP/1.1 \(status) \(reason)\r\n"
678
+ + "Content-Length: 0\r\n"
679
+ + "Connection: close\r\n\r\n"
680
+ }
681
+
682
+ // MARK: - Helpers
683
+
684
+ private static func isBenignDisconnect(_ error: NWError) -> Bool {
685
+ if case .posix(let code) = error {
686
+ return code == .ECONNRESET || code == .ENOTCONN || code == .EPIPE || code == .ECANCELED
687
+ }
688
+ return false
689
+ }
690
+ }
691
+