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
@@ -12,11 +12,11 @@ import AVFoundation
12
12
  /// Constructs AVPlayerItem instances from TrackItem descriptors,
13
13
  /// applying the PlayerConfig's custom HTTP headers + user agent.
14
14
  ///
15
- /// Headers are the load-bearing feature for Subsonic-behind-reverse-
16
- /// proxy deployments every remote fetch (asset read, lookahead-
17
- /// cache download, artwork fetch) has to carry the consumer app's
18
- /// auth. `AVURLAssetHTTPHeaderFieldsKey` on the AVURLAsset options
19
- /// dictionary does this on iOS.
15
+ /// Headers carry the consumer app's auth on every remote fetch —
16
+ /// asset read, lookahead-cache download, artwork fetch. An unrouted
17
+ /// asset gets them through `AVURLAssetHTTPHeaderFieldsKey` on its
18
+ /// options dictionary; a routed one is built with no asset options,
19
+ /// and the read-through server sends them on its origin leg instead.
20
20
  ///
21
21
  /// User-Agent handling: AVURLAsset has no dedicated UA option. The
22
22
  /// standard workaround is to include `User-Agent: <ua>` inside the
@@ -28,19 +28,20 @@ public enum AVQueueBuilder {
28
28
 
29
29
  /// Build AVPlayerItems for every track in `tracks`, applying config
30
30
  /// headers + UA on remote URLs. Returns an empty array for an empty
31
- /// input. Never throws — invalid URLs silently produce a nil asset
32
- /// which AVQueuePlayer surfaces as a playback error at runtime.
31
+ /// input. Never throws — a track whose URL does not parse is dropped
32
+ /// from the result (`makeItem` returns nil), so the engine never sees
33
+ /// it.
33
34
  ///
34
- /// When `cache` is non-nil + a track is fully cached, the
35
- /// AVPlayerItem is built from the local file URL instead of the
36
- /// remote one. Bytes never hit the network on playback. Partial
37
- /// caches fall through to the remote URL `cache.cachedURL`
38
- /// returns nil for entries that haven't fully downloaded.
35
+ /// With a non-nil `server`, every routable remote track (`isRoutable`)
36
+ /// is built on the read-through server's local URL, and the server
37
+ /// decides per request whether bytes come from the lookahead cache or
38
+ /// the origin. `file://` tracks, `.m3u8` manifests, and every track when
39
+ /// `server` is nil bind `track.url` directly see `makeItem`.
39
40
  static func buildPlayerItems(
40
41
  tracks: [TrackItem],
41
42
  queueItemIds: [String],
42
43
  config: PlayerConfig,
43
- cache: LookaheadCache? = nil
44
+ server: ReadThroughServer? = nil
44
45
  ) -> [AVPlayerItem] {
45
46
  // Parallel-array invariant: a violation is a programming error
46
47
  // we own (QueueState uphold-able at construction). `precondition`
@@ -51,41 +52,43 @@ public enum AVQueueBuilder {
51
52
  "AVQueueBuilder.buildPlayerItems requires queueItemIds parallel to tracks (1:1)"
52
53
  )
53
54
  return zip(tracks, queueItemIds).compactMap { track, id in
54
- makeItem(for: track, queueItemId: id, config: config, cache: cache)
55
+ makeItem(for: track, queueItemId: id, config: config, server: server)
55
56
  }
56
57
  }
57
58
 
58
- /// Best-effort warm-up of a freshly-built item's asset BEFORE it is
59
- /// inserted into a live `AVQueuePlayer`. When the insert lands in the
60
- /// immediate-next slot of a playing queue, `AVQueuePlayer` prerolls the
61
- /// new item on the shared decode/render pipeline while the current item
62
- /// is still rendering; faulting the asset's `tracks` / `duration` /
63
- /// `playable` keys in synchronously at that moment briefly disturbs the
64
- /// currently-playing item. Loading them here first makes that preroll
65
- /// cheap.
59
+ /// Cancel outstanding key loads on the assets of items being discarded.
66
60
  ///
67
- /// Best-effort: the load races a [timeout] deadline and the caller
68
- /// proceeds as soon as either wins. `AVAsset`'s async loader honours
69
- /// task cancellation, so `cancelAll` caps a slow / unreachable remote
70
- /// asset at [timeout] and the item is inserted unwarmed rather than
71
- /// deferring the enqueue. Load errors are ignored for the same reason.
72
- @MainActor
73
- static func preloadAssetForInsert(_ item: AVPlayerItem, timeout: TimeInterval = 2.0) async {
74
- let asset = item.asset
75
- await withTaskGroup(of: Void.self) { group in
76
- group.addTask { @MainActor in
77
- _ = try? await asset.load(.tracks, .duration, .isPlayable)
78
- }
79
- group.addTask {
80
- try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
81
- }
82
- _ = await group.next()
83
- group.cancelAll()
61
+ /// `AVURLAsset.dealloc` cancels them implicitly and **blocks the deallocating
62
+ /// thread while it does** a dispatch-group wait on the asset inspector,
63
+ /// measured at tens of seconds for a queue of 200 with loads in flight.
64
+ /// Cancelling first makes that wait a no-op. That thread is
65
+ /// `playerQueue`, so the block stalls every queue operation behind it.
66
+ ///
67
+ /// Only assets that are genuinely being dropped may be cancelled: a
68
+ /// cancelled asset can never become playable, so an item rebuilt on a reused
69
+ /// asset would stall silently. `keeping` is the set that survives, compared
70
+ /// by identity.
71
+ static func cancelLoading(dropping outgoing: [AVPlayerItem], keeping: [AVPlayerItem] = []) {
72
+ for item in itemsBeingDropped(outgoing: outgoing, keeping: keeping) {
73
+ item.asset.cancelLoading()
84
74
  }
85
75
  }
86
76
 
87
- /// Single-item builder. Used by addToQueue. Honours the cache
88
- /// lookup when a cache instance is supplied.
77
+ /// The items in `outgoing` that no entry of `keeping` reuses. Identity, not
78
+ /// equality: two items can wrap the same asset, and it is the asset's
79
+ /// survival that decides whether cancelling it is safe.
80
+ @_spi(QueuePlayerTests)
81
+ public static func itemsBeingDropped(
82
+ outgoing: [AVPlayerItem], keeping: [AVPlayerItem]
83
+ ) -> [AVPlayerItem] {
84
+ guard !keeping.isEmpty else { return outgoing }
85
+ let survivingAssets = keeping.map { ObjectIdentifier($0.asset) }
86
+ let surviving = Set(survivingAssets)
87
+ return outgoing.filter { !surviving.contains(ObjectIdentifier($0.asset)) }
88
+ }
89
+
90
+ /// Single-item builder behind `buildPlayerItems`. Routes a routable
91
+ /// remote track through `server` when one is supplied.
89
92
  ///
90
93
  /// `queueItemId` is the lib-generated identity for this AVPlayerItem
91
94
  /// instance — set on the constructed item via the
@@ -95,53 +98,68 @@ public enum AVQueueBuilder {
95
98
  /// position; the value passed here is the corresponding entry.
96
99
  /// Never derived from any consumer-supplied TrackItem field —
97
100
  /// only `track.url` is required + reliable on consumer data.
101
+ ///
102
+ /// The consumer's ReplayGain claim over [track] is resolved and
103
+ /// validated here, once, and stamped on the item alongside those two.
104
+ /// The item is the only thing the audio path holds, and this is the
105
+ /// one place that has both it and the `TrackItem` it came from.
98
106
  static func makeItem(
99
107
  for track: TrackItem,
100
108
  queueItemId: String,
101
109
  config: PlayerConfig,
102
- cache: LookaheadCache? = nil
110
+ server: ReadThroughServer? = nil
103
111
  ) -> AVPlayerItem? {
104
- if let cachedURL = cache?.cachedURL(forUrl: track.url) {
105
- // Local file URL no headers needed. `cachedURL` is a pure
106
- // read; LRU bookkeeping is opt-in via `touch` so a read-heavy
107
- // playback path doesn't trigger a plist write per resolution.
108
- cache?.touch(url: track.url)
109
- // `automaticallyLoadedAssetKeys` pre-loads the listed keys
110
- // asynchronously while the AVPlayerItem itself becomes
111
- // addressable Apple's documented iOS 16+ preferred
112
- // construction pattern, shaving first-frame latency vs a
113
- // bare `AVPlayerItem(asset:)` that loads keys lazily on first
114
- // access.
115
- //
116
- // `"tracks"` is included here (file:// branch only — the
117
- // HTTP path in `makeItemRaw` keeps the original two-key set
118
- // so HLS / streaming items don't pay the variant-playlist +
119
- // segment-header round-trip at insert time). The local
120
- // tracks-key load is free; once it's loaded, the engine's
121
- // per-item `audioMix(for:)` install (called BEFORE
122
- // `player.insert`) gets a tap-bearing mix synchronously,
123
- // which is the only window AVPlayer honours per Apple's
124
- // contract (`audioMix` changes after the item starts
125
- // rendering are silently dropped).
126
- let asset = AVURLAsset(url: cachedURL)
127
- let item = AVPlayerItem(
112
+ // Remote tracks bind the read-through server's URL, which is stable for
113
+ // the session and never re-pointed. Whether the bytes come from the cache
114
+ // or the origin is the server's decision, made per request — the only
115
+ // moment it can be answered, because the cache fills long after the queue
116
+ // is built. Headers move to the origin leg with it.
117
+ //
118
+ // HLS is excluded: proxying a playlist would re-base its relative segment
119
+ // paths against the local URL. No server means no routing, which is what a
120
+ // consumer running without the lookahead cache gets.
121
+ let routed: URL? = Self.isRoutable(track.url) ? server.flatMap { srv in
122
+ URL(string: track.url).flatMap { srv.localURL(for: $0, sourceURL: track.url) }
123
+ } : nil
124
+
125
+ let item: AVPlayerItem?
126
+ if let routed {
127
+ let asset = AVURLAsset(url: routed)
128
+ let built = AVPlayerItem(
128
129
  asset: asset,
129
- automaticallyLoadedAssetKeys: ["playable", "duration", "tracks"]
130
+ automaticallyLoadedAssetKeys: ["playable", "duration"]
130
131
  )
131
- pinForwardBufferDuration(on: item)
132
- item.queueItemId = queueItemId
133
- item.sourceURL = track.url
134
- return item
132
+ pinForwardBufferDuration(on: built)
133
+ built.isRoutedThroughReadThroughServer = true
134
+ item = built
135
+ } else {
136
+ item = makeItemRaw(
137
+ urlString: track.url,
138
+ headers: config.httpHeaders,
139
+ userAgent: config.userAgent)
135
140
  }
136
- let item = makeItemRaw(
137
- urlString: track.url,
138
- headers: config.httpHeaders,
139
- userAgent: config.userAgent)
140
141
  item?.queueItemId = queueItemId
142
+ // The media's own URL, always — never the transport's. Everything that
143
+ // reads a container, a codec or a track identity out of a URL reads this.
141
144
  item?.sourceURL = track.url
145
+ item?.suppliedReplayGain = SuppliedReplayGain.from(track: track)
142
146
  return item
143
147
  }
144
148
 
149
+ /// Whether a URL is one the read-through server can carry.
150
+ ///
151
+ /// `http(s)` only, and not a manifest: AVFoundation resolves an HLS
152
+ /// playlist's relative segment paths against the URL it fetched the playlist
153
+ /// from, so a proxied playlist would point every segment at the local server
154
+ /// under a token that names none of them.
155
+ static func isRoutable(_ urlString: String) -> Bool {
156
+ guard let url = URL(string: urlString), let scheme = url.scheme?.lowercased(),
157
+ scheme == "http" || scheme == "https"
158
+ else { return false }
159
+ return url.pathExtension.lowercased() != "m3u8"
160
+ }
161
+
162
+
145
163
  /// Apple-acknowledged AVPlayer mitigation: explicitly setting
146
164
  /// `preferredForwardBufferDuration = 0.0` on every item closes a
147
165
  /// long-standing AVQueuePlayer bug where items can be dropped
@@ -198,9 +216,24 @@ public enum AVQueueBuilder {
198
216
  return item
199
217
  }
200
218
 
201
- /// Plain-Swift merging core takes primitive arguments so it stays
202
- /// clear of the Swift↔C++ interop boundary.
203
- /// `makeItemRaw(urlString:headers:userAgent:)` below
219
+ /// The consumer's headers with `userAgent` folded in, which is the request
220
+ /// shape every outbound leg uses: asset loads, cache prefetches, and the
221
+ /// read-through server's origin requests.
222
+ @_spi(QueuePlayerTests)
223
+ public static func mergedHeaders(
224
+ headers: [String: String]?,
225
+ userAgent: String?
226
+ ) -> [String: String] {
227
+ var merged: [String: String] = headers ?? [:]
228
+ if let ua = userAgent, !ua.isEmpty {
229
+ merged["User-Agent"] = ua
230
+ }
231
+ return merged
232
+ }
233
+
234
+ /// Plain-Swift options-dictionary builder — takes primitive arguments so
235
+ /// it stays clear of the Swift↔C++ interop boundary.
236
+ /// `makeItemRaw(urlString:headers:userAgent:)`
204
237
  /// calls this, and `makeItem(for:, config:)` unpacks its `PlayerConfig`
205
238
  /// argument into the same primitive pair and calls `makeItemRaw`.
206
239
  ///
@@ -209,18 +242,20 @@ public enum AVQueueBuilder {
209
242
  ///
210
243
  /// Key spelling: `AVURLAssetHTTPHeaderFieldsKey` is not a public
211
244
  /// Swift API. The raw Obj-C string still works at runtime. See
212
- /// NOTES.md §7. We do not implement an
213
- /// `AVAssetResourceLoaderDelegate` replacement — see
214
- /// `plan/blocked.md`.
245
+ /// NOTES.md §7.
246
+ ///
247
+ /// No `AVAssetResourceLoaderDelegate` is installed, and none will be: an
248
+ /// asset routed through a loader takes a custom URL scheme, and Apple states
249
+ /// AirPlay is unsupported for one. Remote tracks reach the lookahead cache
250
+ /// through a local HTTP server instead, which keeps AVFoundation's own HTTP
251
+ /// stack — including the redirect following a loader would have to
252
+ /// reimplement.
215
253
  @_spi(QueuePlayerTests)
216
254
  public static func httpOptionsRaw(
217
255
  headers: [String: String]?,
218
256
  userAgent: String?
219
257
  ) -> [String: Any] {
220
- var merged: [String: String] = headers ?? [:]
221
- if let ua = userAgent, !ua.isEmpty {
222
- merged["User-Agent"] = ua
223
- }
258
+ let merged = mergedHeaders(headers: headers, userAgent: userAgent)
224
259
  guard !merged.isEmpty else { return [:] }
225
260
  return ["AVURLAssetHTTPHeaderFieldsKey": merged]
226
261
  }
@@ -127,10 +127,3 @@ final class ArtworkLoader {
127
127
  private var _configHeaders: [String: String]?
128
128
  private var _configUserAgent: String?
129
129
  }
130
-
131
- private extension NSLock {
132
- func withLock<T>(_ body: () throws -> T) rethrows -> T {
133
- lock(); defer { unlock() }
134
- return try body()
135
- }
136
- }
@@ -53,11 +53,10 @@ final class ArtworkResolver {
53
53
  /// only one the consumer cares about. Threading: completion fires
54
54
  /// on main.
55
55
  func resolve(url: String?, completion: @escaping (UIImage) -> Void) {
56
- self.currentSeq &+= 1
57
- let seq = self.currentSeq
56
+ let seq = bumpSeq()
58
57
 
59
58
  loader.load(uri: url) { [weak self] image in
60
- guard let self = self, self.currentSeq == seq else { return }
59
+ guard let self = self, self.isCurrent(seq) else { return }
61
60
  completion(image ?? PlaceholderArtwork.image)
62
61
  }
63
62
  }
@@ -68,7 +67,7 @@ final class ArtworkResolver {
68
67
  /// already in transit will populate the loader's cache for a
69
68
  /// future `resolve(...)` that hits the same URL.
70
69
  func cancelInFlight() {
71
- currentSeq &+= 1
70
+ _ = bumpSeq()
72
71
  }
73
72
 
74
73
  /// Clear the underlying loader's cache + drop the in-flight
@@ -83,5 +82,24 @@ final class ArtworkResolver {
83
82
  /// Monotonic resolve-call counter. Captured into completion closures
84
83
  /// to drop responses for superseded requests, including same-URL
85
84
  /// re-resolves that the URL-string compare cannot distinguish.
85
+ /// Callers bump this on the queue that owns playback while the loader
86
+ /// reports completions on main, so the counter is the one piece of state
87
+ /// crossing between them and it carries its own lock.
88
+ private let seqLock = NSLock()
86
89
  private var currentSeq: UInt64 = 0
90
+
91
+ /// Advance the sequence and return the new value.
92
+ private func bumpSeq() -> UInt64 {
93
+ seqLock.lock()
94
+ defer { seqLock.unlock() }
95
+ currentSeq &+= 1
96
+ return currentSeq
97
+ }
98
+
99
+ /// Whether `seq` is still the newest request.
100
+ private func isCurrent(_ seq: UInt64) -> Bool {
101
+ seqLock.lock()
102
+ defer { seqLock.unlock() }
103
+ return currentSeq == seq
104
+ }
87
105
  }
@@ -0,0 +1,118 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// Reads the four ReplayGain tags off an `AVAsset`'s own metadata,
5
+ /// replacing the byte-prefix probe. AVFoundation surfaces the same
6
+ /// tags under three different shapes depending on container:
7
+ ///
8
+ /// - MP3 / ID3v2 `TXXX`: the name lives in the item's `extraAttributes`
9
+ /// info key; the item's own key is the frame id (`TXXX`).
10
+ /// - FLAC / Vorbis comment: the name *is* the item's key.
11
+ /// - M4A / iTunes freeform: the item's key is the reverse-DNS
12
+ /// identifier (`com.apple.iTunes.replaygain_track_gain`).
13
+ ///
14
+ /// `tagName(info:key:)` is the pure reduction of those three shapes to
15
+ /// one comparable name, extracted so it is testable without building
16
+ /// an `AVMetadataItem`.
17
+ enum AssetReplayGainReader {
18
+
19
+ /// The four names a derived tag name has to match to be worth
20
+ /// loading a value for. Everything else on the asset is skipped
21
+ /// before its `stringValue` is touched.
22
+ private static let replayGainNames: Set<String> = [
23
+ ReplayGainExtractor.trackGainKey,
24
+ ReplayGainExtractor.trackPeakKey,
25
+ ReplayGainExtractor.albumGainKey,
26
+ ReplayGainExtractor.albumPeakKey,
27
+ ]
28
+
29
+ /// Read ReplayGain from an item AVPlayer has already prepared.
30
+ ///
31
+ /// Returns `nil` unless `item.status == .readyToPlay`. That single
32
+ /// condition is the whole gate: on a prepared item `load(.metadata)`
33
+ /// costs zero requests and zero bytes, while interrogating an
34
+ /// unprepared asset downloads essentially the entire file (measured:
35
+ /// 2.889 MB of a 2.89 MB MP3, 3.776 MB of a 3.78 MB FLAC).
36
+ ///
37
+ /// Deliberately NOT gated on `asset.statusOfValue(forKey: "metadata")`:
38
+ /// without `"metadata"` in `automaticallyLoadedAssetKeys` that status
39
+ /// reads `.unknown` even at `.readyToPlay` (measured 8/8), so such a
40
+ /// guard would be permanently false. The key is not set because it
41
+ /// buys nothing — the prepared read is already free.
42
+ ///
43
+ /// Values go through `ReplayGainExtractor.rgDataFromTags`, the same
44
+ /// acceptance function every other ReplayGain entry point uses, so
45
+ /// the dB / peak rules cannot drift per source.
46
+ static func read(item: AVPlayerItem) async -> ReplayGainData? {
47
+ guard item.status == .readyToPlay else { return nil }
48
+ // `AVPlayerItem.asset` is a MainActor-isolated property under strict
49
+ // concurrency (`AVAsset` is not Sendable); `status` is explicitly
50
+ // nonisolated. Hop to main for the asset read, then work against the
51
+ // captured reference — same shape as `NowPlayingFormatExtractor.extract`.
52
+ let asset: AVAsset = await MainActor.run { item.asset }
53
+ // Opus applies `OpusHead.output_gain` in the decoder, so applying a
54
+ // `REPLAYGAIN_*` tag on top of it double-corrects (NOTES.md §25).
55
+ //
56
+ // The container is read from `sourceURL`, the media's own URL. A routed
57
+ // track's asset URL is the read-through server's, which carries no
58
+ // extension — asking it would fail this guard open on every Opus track.
59
+ let sourceURL = await MainActor.run { item.sourceURL }
60
+ if let source = sourceURL, let url = URL(string: source), isOpusContainer(url: url) {
61
+ return nil
62
+ }
63
+
64
+ let metadata: [AVMetadataItem]
65
+ do {
66
+ metadata = try await asset.load(.metadata)
67
+ } catch {
68
+ return nil
69
+ }
70
+
71
+ var tags: [String: String] = [:]
72
+ for entry in metadata {
73
+ // `try?` flattens here — both loaders already return an optional,
74
+ // so a load failure and an absent value arrive as the same `nil`.
75
+ let attributes = try? await entry.load(.extraAttributes)
76
+ let info = attributes?[.info] as? String
77
+ guard let name = tagName(info: info, key: entry.key as? String),
78
+ replayGainNames.contains(name) else { continue }
79
+ guard let value = try? await entry.load(.stringValue) else { continue }
80
+ tags[name] = value
81
+ }
82
+ return ReplayGainExtractor.rgDataFromTags(tags)
83
+ }
84
+
85
+ /// Derive the comparable tag name from a metadata item's info key
86
+ /// [info] and its own key [key]: [info] when it is non-blank, else
87
+ /// [key]; then the segment after the last `.`; then uppercased.
88
+ /// Returns `nil` when neither input yields a usable name.
89
+ static func tagName(info: String?, key: String?) -> String? {
90
+ let source: String
91
+ if let info = info, !info.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
92
+ source = info.trimmingCharacters(in: .whitespacesAndNewlines)
93
+ } else if let key = key, !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
94
+ source = key.trimmingCharacters(in: .whitespacesAndNewlines)
95
+ } else {
96
+ return nil
97
+ }
98
+ // Reverse-DNS freeform keys carry the tag name in their last
99
+ // segment; ID3 info fields and Vorbis keys carry no `.` at all and
100
+ // pass through whole.
101
+ let name: String
102
+ if let separator = source.lastIndex(of: ".") {
103
+ name = String(source[source.index(after: separator)...])
104
+ } else {
105
+ name = source
106
+ }
107
+ guard !name.isEmpty else { return nil }
108
+ return name.uppercased()
109
+ }
110
+
111
+ /// True when [url] names an Opus container. Opus carries its gain in
112
+ /// `OpusHead.output_gain`, which the decoder applies, so a
113
+ /// `REPLAYGAIN_*` tag found alongside it would be applied twice.
114
+ static func isOpusContainer(url: URL) -> Bool {
115
+ let ext = url.pathExtension.lowercased()
116
+ return ext == "opus" || ext == "ogg"
117
+ }
118
+ }
@@ -9,7 +9,7 @@ import AVFoundation
9
9
  /// bus, and calls `activate` + `installObservers` during its own
10
10
  /// lifecycle.
11
11
  ///
12
- /// Interruption semantics (matches Apple's docs + RNTP behaviour):
12
+ /// Interruption semantics (per Apple's AVAudioSession interruption docs):
13
13
  ///
14
14
  /// - `.began`: system is telling us to pause (incoming call, Siri,
15
15
  /// FaceTime, alarm). Callers typically call `pause()` on the
@@ -54,21 +54,37 @@ final class AudioSession {
54
54
  case newDeviceAvailable
55
55
  }
56
56
 
57
- /// Fires on AVAudioSession interruption notifications. Stored
58
- /// on main thread (registration happens on main); invoked on
59
- /// main (NotificationCenter body dispatches before calling).
60
- var onInterruption: ((InterruptionKind) -> Void)?
57
+ /// The three callback slots are written by the owner on its player queue
58
+ /// (`configure()` / `destroy()`) and read on main by the notification
59
+ /// observers and `onError` on the player queue as well — so each read and
60
+ /// write goes through one lock.
61
+ private let callbackLock = NSLock()
62
+ private var interruptionCallback: ((InterruptionKind) -> Void)?
63
+ private var routeChangeCallback: ((RouteChangeKind) -> Void)?
64
+ private var errorCallback: ((AudioSessionError) -> Void)?
65
+
66
+ /// Fires on AVAudioSession interruption notifications, on main.
67
+ var onInterruption: ((InterruptionKind) -> Void)? {
68
+ get { callbackLock.withLock { interruptionCallback } }
69
+ set { callbackLock.withLock { interruptionCallback = newValue } }
70
+ }
61
71
 
62
72
  /// Fires on AVAudioSession route-change notifications for the
63
- /// subset of reasons the caller should react to.
64
- var onRouteChange: ((RouteChangeKind) -> Void)?
73
+ /// subset of reasons the caller should react to, on main.
74
+ var onRouteChange: ((RouteChangeKind) -> Void)? {
75
+ get { callbackLock.withLock { routeChangeCallback } }
76
+ set { callbackLock.withLock { routeChangeCallback = newValue } }
77
+ }
65
78
 
66
79
  /// Fires when an AVAudioSession lifecycle call (`setCategory`,
67
80
  /// `setActive(true)`, `setActive(false)`) throws. Failure is
68
81
  /// non-fatal at the audio-session level — playback itself hasn't
69
82
  /// started yet, so the player state machine should not transition
70
- /// to `.error` on these (do not call `emitState` from this hook).
71
- var onError: ((AudioSessionError) -> Void)?
83
+ /// to `.error` on these (do not call `emitState` from this callback).
84
+ var onError: ((AudioSessionError) -> Void)? {
85
+ get { callbackLock.withLock { errorCallback } }
86
+ set { callbackLock.withLock { errorCallback = newValue } }
87
+ }
72
88
 
73
89
  private var interruptionObserver: NSObjectProtocol?
74
90
  private var routeChangeObserver: NSObjectProtocol?
@@ -99,20 +115,50 @@ final class AudioSession {
99
115
  mode: AVAudioSession.Mode = .default,
100
116
  options: AVAudioSession.CategoryOptions = []
101
117
  ) {
102
- let session = AVAudioSession.sharedInstance()
118
+ guard configureCategory(mode: mode, options: options) else { return }
119
+ activateIfNeeded()
120
+ }
121
+
122
+ /// Apply the playback category without claiming the session. Returns whether
123
+ /// it succeeded, since activating after a failed category is pointless.
124
+ ///
125
+ /// Category and activation are separable on purpose: the category is what
126
+ /// route sharing and the interruption observers need, while activation is
127
+ /// what actually takes audio away from whatever else is playing. Doing both
128
+ /// at configure time silenced another app's audio the moment a consumer
129
+ /// launched, before the user had pressed anything.
130
+ @discardableResult
131
+ func configureCategory(
132
+ mode: AVAudioSession.Mode = .default,
133
+ options: AVAudioSession.CategoryOptions = []
134
+ ) -> Bool {
103
135
  do {
104
- try session.setCategory(.playback, mode: mode, options: options)
136
+ try AVAudioSession.sharedInstance().setCategory(.playback, mode: mode, options: options)
137
+ return true
105
138
  } catch {
106
139
  let desc = String(describing: error)
107
140
  NSLog("[AudioSession] setCategory(.playback) failed: %@", desc)
108
141
  onError?(.setCategoryFailed(message: desc, nativeCode: (error as NSError).code))
109
- // Early-return: if setCategory failed, setActive is highly
110
- // likely to fail too — no value in chaining a second error
111
- // for the same root cause.
112
- return
142
+ // If setCategory failed, setActive is highly likely to fail too — no
143
+ // value in chaining a second error for the same root cause.
144
+ return false
113
145
  }
146
+ }
147
+
148
+ /// The system took the session for an interruption. A later
149
+ /// `activateIfNeeded` claims it again: the session is no longer ours
150
+ /// whatever this object recorded before the interruption.
151
+ func noteInterrupted() {
152
+ isActive = false
153
+ }
154
+
155
+ /// Claim the audio session. Idempotent: repeated calls after the first are
156
+ /// no-ops, so every playback entry point can call it without bookkeeping.
157
+ func activateIfNeeded() {
158
+ guard !isActive else { return }
114
159
  do {
115
- try session.setActive(true)
160
+ try AVAudioSession.sharedInstance().setActive(true)
161
+ isActive = true
116
162
  } catch {
117
163
  let desc = String(describing: error)
118
164
  NSLog("[AudioSession] setActive(true) failed: %@", desc)
@@ -120,11 +166,16 @@ final class AudioSession {
120
166
  }
121
167
  }
122
168
 
169
+ /// Whether this object has claimed the session. Cleared on deactivate so a
170
+ /// later play re-claims it.
171
+ private var isActive = false
172
+
123
173
  /// Deactivate the session + notify other apps they can resume.
124
174
  /// Consumers call this during `destroy` so e.g. a paused music
125
175
  /// app or podcast player can auto-resume. Same error-logging
126
176
  /// rationale as `activate`.
127
177
  func deactivate() {
178
+ isActive = false
128
179
  do {
129
180
  try AVAudioSession.sharedInstance().setActive(
130
181
  false, options: [.notifyOthersOnDeactivation])