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
@@ -26,7 +26,8 @@ import Foundation
26
26
  /// Threading:
27
27
  /// - Consumer registration runs on the JS bridge thread at Hybrid
28
28
  /// construction.
29
- /// - `audioMix(for:)` runs on main (engine call site).
29
+ /// - `audioMix(for:)` runs on the mix dispatch target — main by
30
+ /// default, `TrackPlayer`'s `playerQueue` once it wires in.
30
31
  /// - `deliverAudioFrame` runs on the audio render thread; the
31
32
  /// per-frame fan-out walks an immutable consumer snapshot under
32
33
  /// [consumersLock] and is allocation-free.
@@ -34,8 +35,8 @@ final class AudioTapProvider: AudioMixProvider {
34
35
 
35
36
  /// Module-internal singleton. Both `Equalizer` and `Visualizer`
36
37
  /// register against this instance at Hybrid construction time. The
37
- /// constructor seam (`AudioTapProvider()`) stays available for
38
- /// tests that need an isolated provider.
38
+ /// constructor (`AudioTapProvider()`) stays available for tests
39
+ /// that need an isolated provider.
39
40
  static let shared = AudioTapProvider()
40
41
 
41
42
  /// Consumer of the post-EQ audio pipeline. Equalizer + Visualizer
@@ -45,15 +46,15 @@ final class AudioTapProvider: AudioMixProvider {
45
46
  final class Consumer {
46
47
  /// Returns true when this consumer needs the tap installed on
47
48
  /// the active player items right now (EQ enabled / visualizer
48
- /// has subscribers). Polled from `audioMix(for:)` on the main
49
- /// thread.
49
+ /// has subscribers). Polled from `audioMix(for:)` on the mix
50
+ /// dispatch target.
50
51
  let wantsTap: () -> Bool
51
52
  /// Render-thread callback fired once per render batch with the
52
53
  /// post-EQ mono mixdown. Must not allocate, lock, or
53
54
  /// synchronously dispatch.
54
55
  let onAudioFrame: (UnsafePointer<Float>, Int, Double) -> Void
55
- /// Fired on the main thread from `audioMix(for:)` whenever a new
56
- /// item becomes the candidate for tap install. Fires for EVERY
56
+ /// Fired on the mix dispatch target from `audioMix(for:)` whenever
57
+ /// a new item becomes the candidate for tap install. Fires for EVERY
57
58
  /// candidate item the engine queues, including items where the
58
59
  /// tap will not install (HLS, format-detection failure, no
59
60
  /// consumer wantsTap). Consumers that need post-install
@@ -76,8 +77,9 @@ final class AudioTapProvider: AudioMixProvider {
76
77
  /// Lock protecting `currentGains` + `enabled` + `consumers`.
77
78
  /// Acquired on the audio render thread (gain snapshot), the JS
78
79
  /// bridge (consumer registration + state mutation), and the
79
- /// playback-engine main path (`audioMix(for:)`); short critical
80
- /// section keeps render-thread cost negligible.
80
+ /// playback engine's mix path (`audioMix(for:)` on the mix dispatch
81
+ /// target); short critical section keeps render-thread cost
82
+ /// negligible.
81
83
  private let stateLock = NSLock()
82
84
 
83
85
  private var currentGains: [Double]
@@ -100,6 +102,17 @@ final class AudioTapProvider: AudioMixProvider {
100
102
  /// destroy + reconfigure).
101
103
  private weak var playbackEngine: PlaybackEngine?
102
104
 
105
+ /// Dispatch target for the engine-facing mix work in
106
+ /// `wireToPlaybackEngine`, `detachFromEngine` and
107
+ /// `refreshActiveItemMixes`. Defaults to the
108
+ /// main queue; `setMixDispatchTarget` repoints it at whichever
109
+ /// queue serialises player access and `resetMixDispatchTarget`
110
+ /// puts it back.
111
+ private var mixQueue: DispatchQueue = .main
112
+ /// Reports whether the calling thread is already executing on
113
+ /// `mixQueue`, so an in-queue caller runs inline.
114
+ private var mixQueueIsCurrent: () -> Bool = { Thread.isMainThread }
115
+
103
116
  /// Live `ReplayGain` state for every queued `AVPlayerItem`. Each
104
117
  /// `audioMix(for:)` allocates a fresh `ItemRGState`, attaches it
105
118
  /// to the item via objc associated-object so the state's lifetime
@@ -120,63 +133,97 @@ final class AudioTapProvider: AudioMixProvider {
120
133
  /// `replayGainDataForItem == nil` (the extract is still in flight)
121
134
  /// and the cached format never refreshes when RG values eventually
122
135
  /// land. Receives the AVPlayerItem the extract completed for.
123
- var onItemReplayGainDataPopulated: ((AVPlayerItem) -> Void)?
124
-
125
- /// Internal RG Consumer registered at construction. Holds the tap
126
- /// installed unconditionally so a `setReplayGainMode` off → on
127
- /// transition affects the currently-playing item without needing to
128
- /// re-install `AVPlayerItem.audioMix`. AVPlayer silently drops a
129
- /// fresh `audioMix` assignment on items that have already begun
130
- /// rendering (see comment on `GaplessEngine.refreshAllItemMixes`),
131
- /// so gating the tap on `replayGainMode != .off` meant mode-flip
132
- /// during playback never actually applied gain to the active track.
133
- ///
134
- /// The cost of an always-installed tap when RG is off is negligible:
135
- /// `ReplayGainProcessor.process` early-returns on a unity-gain
136
- /// branch test at the head of each callback. EQ + Visualizer
137
- /// consumers still gate their own contribution to the composed tap
138
- /// closure independently; this Consumer only ensures the tap
139
- /// scaffolding is in place so the RG processor's gain (which `recomputeWithLock`
140
- /// can update at any time, thread-safely) is read by the audio
141
- /// render thread on every buffer batch.
142
- private var replayGainTapConsumer: Consumer?
136
+ var onItemReplayGainDataPopulated: ((AVPlayerItem) -> Void)? {
137
+ get { stateLock.withLock { replayGainPopulatedCallback } }
138
+ set { stateLock.withLock { replayGainPopulatedCallback = newValue } }
139
+ }
140
+ /// Written by `TrackPlayer` on its player queue; read under `stateLock`
141
+ /// where the extract completes.
142
+ private var replayGainPopulatedCallback: ((AVPlayerItem) -> Void)?
143
143
 
144
144
  init(initialGains: [Double] = Array(repeating: 0, count: EQProcessor.bandCount)) {
145
145
  self.currentGains = initialGains
146
- let consumer = Consumer(
147
- wantsTap: { true },
148
- onAudioFrame: { _, _, _ in },
149
- onActiveAssetChange: { _ in }
150
- )
151
- self.replayGainTapConsumer = consumer
152
- register(consumer)
146
+ // A consumer that always wants the tap, registered for the provider's
147
+ // life, so the tap is installed regardless of EQ / visualizer state and
148
+ // a `setReplayGainMode` off on transition affects the currently-playing
149
+ // item without re-installing `AVPlayerItem.audioMix` — AVPlayer silently
150
+ // drops a fresh `audioMix` on an item that has begun rendering (see
151
+ // `GaplessEngine.refreshAllItemMixes`). The cost with RG off is
152
+ // negligible: `ReplayGainProcessor.process` early-returns on a unity-gain
153
+ // test at the head of each callback, leaving the pass-through EQ and the
154
+ // mono mixdown. EQ and Visualizer consumers gate their own contribution
155
+ // independently; this one only keeps the tap in place so the RG gain
156
+ // `recomputeWithLock` updates is read by the audio render thread on
157
+ // every buffer batch.
158
+ register(
159
+ Consumer(
160
+ wantsTap: { true },
161
+ onAudioFrame: { _, _, _ in },
162
+ onActiveAssetChange: { _ in }
163
+ ))
153
164
  }
154
165
 
155
166
  // MARK: - Engine wiring
156
167
 
168
+ /// Route the provider's engine calls through [queue]. [queue] must
169
+ /// serialise all player access, and [isCurrent] must report whether
170
+ /// the calling thread is already on it — a caller that is runs the
171
+ /// work inline instead of deferring it behind an async hop. Defaults
172
+ /// to the main queue.
173
+ func setMixDispatchTarget(_ queue: DispatchQueue, isCurrent: @escaping () -> Bool) {
174
+ stateLock.lock()
175
+ mixQueue = queue
176
+ mixQueueIsCurrent = isCurrent
177
+ stateLock.unlock()
178
+ }
179
+
180
+ /// Put the engine-facing mix work back on the main queue. The provider
181
+ /// outlives any one player, so a teardown that leaves a player-owned queue
182
+ /// installed here would dispatch the next player's mix work onto a dead one.
183
+ func resetMixDispatchTarget() {
184
+ stateLock.lock()
185
+ mixQueue = .main
186
+ mixQueueIsCurrent = { Thread.isMainThread }
187
+ stateLock.unlock()
188
+ }
189
+
157
190
  /// Wire the provider to [engine] so per-item `audioMix(for:)`
158
191
  /// queries route through this provider, and so consumers can ask
159
- /// for an engine-wide refresh when their `wantsTap` flips.
192
+ /// for an engine-wide refresh when their `wantsTap` flips. Hops to
193
+ /// the mix dispatch target if needed — AVFoundation expects
194
+ /// `setAudioMixProvider` on the queue that serialises player access.
160
195
  func wireToPlaybackEngine(_ engine: PlaybackEngine) {
161
196
  stateLock.lock()
162
197
  self.playbackEngine = engine
198
+ let queue = mixQueue
199
+ let isCurrent = mixQueueIsCurrent
163
200
  stateLock.unlock()
164
- engine.setAudioMixProvider(self)
201
+ // `setAudioMixProvider` refreshes every item's mix, which walks the
202
+ // player, so it takes the same hop `detachFromEngine` does.
203
+ if isCurrent() {
204
+ engine.setAudioMixProvider(self)
205
+ } else {
206
+ queue.async {
207
+ engine.setAudioMixProvider(self)
208
+ }
209
+ }
165
210
  }
166
211
 
167
212
  /// Detach the provider from any previously-wired engine. Hops to
168
- /// main if needed — AVFoundation expects `setAudioMixProvider` on
169
- /// the main thread.
213
+ /// the mix dispatch target if needed — AVFoundation expects
214
+ /// `setAudioMixProvider` on the queue that serialises player access.
170
215
  func detachFromEngine() {
171
216
  stateLock.lock()
172
217
  let engine = self.playbackEngine
173
218
  self.playbackEngine = nil
219
+ let queue = mixQueue
220
+ let isCurrent = mixQueueIsCurrent
174
221
  stateLock.unlock()
175
222
  guard let engine = engine else { return }
176
- if Thread.isMainThread {
223
+ if isCurrent() {
177
224
  engine.setAudioMixProvider(nil)
178
225
  } else {
179
- DispatchQueue.main.async {
226
+ queue.async {
180
227
  engine.setAudioMixProvider(nil)
181
228
  }
182
229
  }
@@ -185,17 +232,20 @@ final class AudioTapProvider: AudioMixProvider {
185
232
  /// Ask the active engine to re-evaluate every item's audioMix. A
186
233
  /// consumer calls this when its `wantsTap` would change (EQ enable
187
234
  /// toggle, first visualizer subscriber arriving, last subscriber
188
- /// dropping). Hops to main; AVFoundation expects `AVPlayer.items()`
189
- /// + `AVPlayerItem.audioMix` on the main thread.
235
+ /// dropping). Hops to the mix dispatch target; AVFoundation expects
236
+ /// `AVPlayer.items()` + `AVPlayerItem.audioMix` on the queue that
237
+ /// serialises player access.
190
238
  func refreshActiveItemMixes() {
191
239
  stateLock.lock()
192
240
  let engine = self.playbackEngine
241
+ let queue = mixQueue
242
+ let isCurrent = mixQueueIsCurrent
193
243
  stateLock.unlock()
194
244
  guard let engine = engine else { return }
195
- if Thread.isMainThread {
245
+ if isCurrent() {
196
246
  engine.refreshAllItemMixes()
197
247
  } else {
198
- DispatchQueue.main.async {
248
+ queue.async {
199
249
  engine.refreshAllItemMixes()
200
250
  }
201
251
  }
@@ -203,10 +253,25 @@ final class AudioTapProvider: AudioMixProvider {
203
253
 
204
254
  // MARK: - Consumer registration
205
255
 
206
- /// Register [consumer] for post-EQ audio frames + active-asset
256
+ /// Broadcast which asset is actually active, without touching audio mixes.
257
+ ///
258
+ /// `audioMix(for:)` announces its argument, which is right when the engine
259
+ /// is attaching the item about to play and wrong when something walks the
260
+ /// queue: the last item walked would be left as the active asset. Callers
261
+ /// that refresh several items re-assert the playing one through here.
262
+ func noteActiveAsset(_ asset: AVAsset?) {
263
+ stateLock.lock()
264
+ let consumerSnapshot = consumers
265
+ stateLock.unlock()
266
+ for consumer in consumerSnapshot {
267
+ consumer.onActiveAssetChange(asset)
268
+ }
269
+ }
270
+
271
+ /// Register [consumer] for post-EQ audio frames and active-asset
207
272
  /// notifications. The provider holds the consumer strongly until
208
- /// `unregister` runs. Order of registration does not matter — both
209
- /// callbacks fire under the same snapshot iteration.
273
+ /// `unregister` runs. Order of registration does not matter — both callbacks
274
+ /// fire under the same snapshot iteration.
210
275
  func register(_ consumer: Consumer) {
211
276
  stateLock.lock()
212
277
  consumers.append(consumer)
@@ -225,7 +290,7 @@ final class AudioTapProvider: AudioMixProvider {
225
290
  /// true. Snapshots the consumer list under the lock and evaluates
226
291
  /// each predicate outside it so a misbehaving predicate cannot
227
292
  /// block render-thread fan-out.
228
- private func anyConsumerWantsTap() -> Bool {
293
+ func anyConsumerWantsTap() -> Bool {
229
294
  stateLock.lock()
230
295
  let snapshot = consumers
231
296
  stateLock.unlock()
@@ -237,8 +302,8 @@ final class AudioTapProvider: AudioMixProvider {
237
302
 
238
303
  // MARK: - EQ master gain state
239
304
 
240
- /// Lock-protected snapshot of the enabled flag. Called from main
241
- /// on every `audioMix(for:)` invocation.
305
+ /// Lock-protected snapshot of the enabled flag. Called on the mix
306
+ /// dispatch target on every `audioMix(for:)` invocation.
242
307
  func isEnabledSnapshot() -> Bool {
243
308
  stateLock.lock()
244
309
  let value = enabled
@@ -359,14 +424,21 @@ final class AudioTapProvider: AudioMixProvider {
359
424
  }
360
425
 
361
426
  /// Pure gain math per NOTES.md §25. Selects the `(gainDb, peak)`
362
- /// pair from [data] under [mode] (strict, no fallback), then:
427
+ /// pair from [data] under [mode] (strict, no fallback), then caps
428
+ /// the result for clip safety:
363
429
  ///
364
430
  /// ```
365
- /// linearGain = pow(10, gainDb / 20)
366
- /// if peak != nil and peak > 0.001 and peak != 1.0:
367
- /// linearGain = min(linearGain, 1.0 / peak)
431
+ /// cap = usable peak ? 1.0 / peak : 1.0
432
+ /// linearGain = min(pow(10, gainDb / 20), cap)
368
433
  /// ```
369
434
  ///
435
+ /// A peak is usable inside the `0.001 ... 2.0` band that
436
+ /// `ReplayGainExtractor.acceptPeak` holds — full scale (`1.0`) and
437
+ /// true peaks above it included. With no usable peak the cap sits at
438
+ /// unity, so a boost is suppressed rather than applied unprotected: a
439
+ /// peak cannot be derived from a gain. Attenuation is untouched
440
+ /// either way, since a negative gain can never clip.
441
+ ///
370
442
  /// Returns 1.0 (unity) whenever the selected mode's gain tag is
371
443
  /// absent — RG is inactive for that track.
372
444
  ///
@@ -377,11 +449,12 @@ final class AudioTapProvider: AudioMixProvider {
377
449
  static func computeLinearGain(data: ReplayGainData?, mode: ReplayGainSelectorMode) -> Float {
378
450
  let (gainDb, peak) = data?.selectGain(mode: mode) ?? (nil, nil)
379
451
  guard let gainDb = gainDb else { return 1.0 }
380
- var raw = Float(pow(10.0, gainDb / 20.0))
381
- if let peak = peak, peak > 0.001, peak != 1.0 {
382
- raw = min(raw, Float(1.0 / peak))
452
+ let raw = Float(pow(10.0, gainDb / 20.0))
453
+ var cap: Float = 1.0
454
+ if let peak = peak, peak >= 0.001, peak <= 2.0 {
455
+ cap = Float(1.0 / peak)
383
456
  }
384
- return raw
457
+ return min(raw, cap)
385
458
  }
386
459
 
387
460
  /// Map the Nitrogen-generated `ReplayGainMode` to the pure-Swift
@@ -417,31 +490,92 @@ final class AudioTapProvider: AudioMixProvider {
417
490
  item.replayGainState?.snapshotData()
418
491
  }
419
492
 
420
- /// Idempotently attach an `ItemRGState` to the item and kick off
421
- /// the one-shot `MetadataReader` extract Task. Called from
422
- /// `audioMix(for:)` BEFORE the tap-mix bypass conditions so RG
423
- /// values populate independently of AVFoundation's track-loading
424
- /// lifecycle. Re-invocation on the same item (e.g. when the
425
- /// engine re-resolves mixes after tracks load) is a no-op.
426
- func ensureReplayGainExtraction(for item: AVPlayerItem) {
427
- guard item.replayGainState == nil else { return }
428
- let rgState = ItemRGState(processor: ReplayGainProcessor())
493
+ /// Attach per-item ReplayGain state and arm the read of the item's own
494
+ /// metadata. Called from `audioMix(for:)` BEFORE the tap-mix bypass
495
+ /// conditions so RG values populate independently of AVFoundation's
496
+ /// track-loading lifecycle. Re-invocation on the same item is a no-op.
497
+ ///
498
+ /// Issues no network read of its own. `AssetReplayGainReader` reads the
499
+ /// metadata AVPlayer has already prepared, which costs zero requests and
500
+ /// zero bytes so the read is bounded by AVPlayer's own preroll horizon
501
+ /// (measured: exactly one prepared item out of eight enqueued) rather than
502
+ /// by how many items the engine has queued.
503
+ @discardableResult
504
+ func attachReplayGainState(to item: AVPlayerItem) -> ItemRGState {
505
+ if let existing = item.replayGainState { return existing }
506
+ let rgState = ItemRGState(processor: ReplayGainProcessor(), item: item)
429
507
  item.replayGainState = rgState
430
508
  stateLock.lock()
431
509
  activeItemStates.add(rgState)
432
510
  stateLock.unlock()
433
- guard let urlAsset = item.asset as? AVURLAsset else { return }
434
- let url = urlAsset.url
435
- Task { [weak rgState, weak item] in
436
- let data = await ReplayGainExtractor.extract(from: url)
437
- guard let rgState = rgState else { return }
438
- rgState.setData(data)
439
- self.recomputeWithLock(state: rgState)
440
- if let item = item {
441
- await MainActor.run {
442
- self.onItemReplayGainDataPopulated?(item)
443
- }
444
- }
511
+
512
+ // `.new` only, never `.initial` (NOTES.md §18), paired with the explicit
513
+ // synchronous bootstrap below.
514
+ rgState.statusObservation = item.observe(\.status, options: [.new]) {
515
+ [weak self, weak rgState] observed, _ in
516
+ guard let self = self, let rgState = rgState else { return }
517
+ self.readReplayGainWhenPrepared(state: rgState, item: observed)
518
+ }
519
+ // Explicit synchronous bootstrap, because an observer waiting for a
520
+ // transition that has already happened never fires. `audioMix(for:)`
521
+ // reaches this method from `refreshActiveItemMixes()` as well as from
522
+ // queue construction — on every ReplayGain mode crossing of the off /
523
+ // non-off boundary, and on crossfade cross-attach — and those calls can
524
+ // carry an item that is already `.readyToPlay`. Whether a given release's
525
+ // engine wiring produces such a first attach is not something this method
526
+ // can know, which is exactly why the bootstrap is unconditional.
527
+ readReplayGainWhenPrepared(state: rgState, item: item)
528
+ return rgState
529
+ }
530
+
531
+ /// Resolve [item]'s ReplayGain once it is prepared, and deliver it into
532
+ /// [state]. No-op until `.readyToPlay`, and at most once per state — a
533
+ /// prepared item's metadata does not change under it, and both the observer
534
+ /// and the bootstrap can reach a ready item.
535
+ ///
536
+ /// The consumer's claim is stamped on the item at queue-build time, and a
537
+ /// claimed item is never read: the merge discards detection in full, so
538
+ /// reading the file's tags could only produce a value to throw away. That
539
+ /// also means the merge happens before the first value is delivered rather
540
+ /// than after — a listener never sees a detected value corrected to a
541
+ /// supplied one.
542
+ ///
543
+ /// Delivery is unconditional on the result: a resolve that found nothing
544
+ /// still lands, so the now-playing snapshot refreshes and reports the honest
545
+ /// absence rather than staying at whatever the pre-read snapshot said.
546
+ private func readReplayGainWhenPrepared(state: ItemRGState, item: AVPlayerItem) {
547
+ guard item.status == .readyToPlay else { return }
548
+ guard state.beginReplayGainRead() else { return }
549
+ let supplied = item.suppliedReplayGain
550
+ Task { [weak self] in
551
+ let detected = supplied == nil ? await AssetReplayGainReader.read(item: item) : nil
552
+ guard let self = self else { return }
553
+ let data = ReplayGainData.merge(
554
+ suppliedClaimed: supplied != nil, supplied: supplied?.data, detected: detected
555
+ )
556
+ state.setData(data)
557
+ self.recomputeWithLock(state: state)
558
+ // Required, not optional: this is what refreshes the emitted now-playing
559
+ // snapshot when the read lands. Without it `onNowPlayingFormatChange`
560
+ // reports null ReplayGain forever, while `getNowPlayingFormat()` stays
561
+ // correct because it reads live — so a polling test would not notice.
562
+ //
563
+ // Hopped onto the mix dispatch target rather than fired inline: the
564
+ // bootstrap path runs inside `audioMix(for:)`, which the engine calls
565
+ // mid-`setItems` between `canInsert` and `insert`; the notification
566
+ // reaches JS, and a listener mutating the queue there would mutate
567
+ // the queue the engine is building. The callback is snapshotted here
568
+ // rather than re-read on the target queue so the hop captures a
569
+ // value, not the non-Sendable provider.
570
+ // Both under the lock that guards them: this runs on the cooperative
571
+ // pool, and `mixQueue` is rewritten by `setMixDispatchTarget` and reset
572
+ // by teardown. Reading it unguarded can dispatch player-state work onto
573
+ // a queue that is no longer the one serialising it.
574
+ let (notify, queue) = stateLock.withLock { (self.replayGainPopulatedCallback, self.mixQueue) }
575
+ // The consumer reads the active item and writes the last-emitted format,
576
+ // which is player state — so this lands on the queue that serialises it,
577
+ // not on main.
578
+ queue.async { notify?(item) }
445
579
  }
446
580
  }
447
581
 
@@ -468,16 +602,17 @@ final class AudioTapProvider: AudioMixProvider {
468
602
  // MARK: - AudioMixProvider
469
603
 
470
604
  /// Builds the per-`AVPlayerItem` `AVAudioMix` that carries the EQ
471
- /// tap + ReplayGain pre-gain stage. Called from the engine's main-
472
- /// thread queue construction path on every fresh item. The body
605
+ /// tap + ReplayGain pre-gain stage. Called on the mix dispatch
606
+ /// target from the engine's item construction path on every fresh
607
+ /// item. The body
473
608
  /// briefly holds `stateLock` three times — once to snapshot the
474
609
  /// consumer list for the asset-change fan-out, once inside
475
610
  /// `anyConsumerWantsTap` to read the consumer snapshot, once to
476
611
  /// register the per-item ReplayGain state. All three critical
477
612
  /// sections are constant-time (consumer count is O(2): EQ +
478
613
  /// Visualizer) and never call out to JS while the lock is held,
479
- /// so the main-thread cost is sub-millisecond on a typical
480
- /// consumer config.
614
+ /// so the cost on the calling queue is sub-millisecond on a
615
+ /// typical consumer config.
481
616
  func audioMix(for item: AVPlayerItem) -> AVAudioMix? {
482
617
  let asset = item.asset
483
618
  // Notify every registered consumer of the candidate asset before
@@ -490,71 +625,50 @@ final class AudioTapProvider: AudioMixProvider {
490
625
  consumer.onActiveAssetChange(asset)
491
626
  }
492
627
 
493
- // Eagerly attach RG state + spawn one-shot metadata extract for
494
- // this item, regardless of the tap-mix bypass conditions. RG
495
- // only needs the source URL `MetadataReader` reads the file
496
- // header bytes directly and never touches AVFoundation's demux
497
- // pipeline. Decoupling it from `audioMix(for:)`'s tracks-loaded /
498
- // format-descriptions / ASBD gates means RG values surface
499
- // within milliseconds of `setQueue`, even for fresh remote
500
- // items whose AVAsset track key hasn't loaded yet.
501
- ensureReplayGainExtraction(for: item)
628
+ // Eagerly attach RG state + arm the metadata read for this item,
629
+ // regardless of the tap-mix bypass conditions. The read waits on
630
+ // `.readyToPlay`, which is a different and later gate than this
631
+ // method's tracks-loaded / format-descriptions / ASBD checks, so
632
+ // attaching here rather than below them means a mode change or a
633
+ // mix refresh can never leave an item without RG state.
634
+ attachReplayGainState(to: item)
502
635
 
503
636
  if !anyConsumerWantsTap() { return nil }
504
637
 
505
638
  if EQTap.isHLS(asset) { return nil }
506
639
 
507
- // Tracks-loaded gate: `asset.tracks(withMediaType:)` is a
508
- // synchronous accessor that BLOCKS the calling thread to load
509
- // tracks if the "tracks" key isn't already loaded. Engine
510
- // queue-mutation paths invoke this method on the main thread
511
- // for fresh items whose remote tracks haven't loaded yet — a
512
- // synchronous block there freezes the JS bridge for the
513
- // duration of the network fetch. Returning nil keeps the main
514
- // thread free; once tracks load, the engine fires
640
+ // Tracks-loaded gate: `status(of:)` reports the tracks key's
641
+ // current load state without loading it, and the mix has no
642
+ // `AVAssetTrack` to key its input parameters to until that key
643
+ // is loaded. Engine queue-mutation paths invoke this method on
644
+ // the mix dispatch target for fresh items whose remote tracks
645
+ // haven't loaded yet; returning nil rather than waiting on the load
646
+ // keeps the JS bridge free. Once tracks load, the engine fires
515
647
  // `engineActiveItemFormatChanged` and the TrackPlayer-level
516
648
  // handler re-fires `refreshActiveItemMixes`, which re-evaluates
517
649
  // every queued item's mix off the now-loaded tracks key.
518
- var keyError: NSError?
519
- let status = asset.statusOfValue(forKey: "tracks", error: &keyError)
520
- guard status == .loaded else { return nil }
521
-
522
- guard
523
- let track = asset.tracks(withMediaType: .audio).first,
524
- let format = track.formatDescriptions.first as CFTypeRef?,
525
- CFGetTypeID(format) == CMFormatDescriptionGetTypeID()
526
- else {
527
- return nil
528
- }
529
- let cmFormat = format as! CMFormatDescription
530
- guard let asbd = readASBD(cmFormat) else { return nil }
650
+ guard case .loaded(let tracks) = asset.status(of: .tracks) else { return nil }
531
651
 
532
- let sampleRate = asbd.mSampleRate
533
- let channelCount = Int(asbd.mChannelsPerFrame)
534
- if sampleRate <= 0 || channelCount <= 0 {
535
- return nil
536
- }
652
+ // An audio track is what the mix keys its input parameters to. The
653
+ // cascade's sample rate and channel count come from the tap's prepare
654
+ // callback instead of the track's advertised format: the tap reports the
655
+ // format it will actually deliver, which is what the DSP has to match,
656
+ // and asking the track means reading a key nothing in the playback
657
+ // lifecycle ever loads.
658
+ guard tracks.contains(where: { $0.mediaType == .audio }) else { return nil }
537
659
 
538
660
  let processor = EQProcessor(provider: self)
539
- processor.setSampleRate(sampleRate)
540
- processor.setChannelCount(channelCount)
541
661
  // The processor's first tap callback compares its initial
542
662
  // version (0) against the provider's current version and pulls
543
663
  // the gains under the lock — avoids a double-recompute on item
544
664
  // construction.
545
665
 
546
- // `ensureReplayGainExtraction(for:)` already attached the
547
- // per-item rgState; reuse it here so the tap composition
548
- // shares the same processor whose gain `recomputeWithLock`
549
- // updates as the mode flips.
550
- let rgState = item.replayGainState ?? {
551
- let s = ItemRGState(processor: ReplayGainProcessor())
552
- item.replayGainState = s
553
- stateLock.lock()
554
- activeItemStates.add(s)
555
- stateLock.unlock()
556
- return s
557
- }()
666
+ // The audioMix entry point already attached the per-item rgState; reuse it
667
+ // so the tap composition shares the same processor whose gain
668
+ // `recomputeWithLock` updates as the mode flips.
669
+ // Both creation sites route through one function so they cannot diverge:
670
+ // this one is shadowed today only because the other always runs first.
671
+ let rgState = attachReplayGainState(to: item)
558
672
 
559
673
  let eqClosure = processor.tapProcessClosure()
560
674
  let rgProcessor = rgState.processor
@@ -568,23 +682,18 @@ final class AudioTapProvider: AudioMixProvider {
568
682
  return EQTap.audioMix(
569
683
  for: item,
570
684
  process: composedClosure,
571
- // Size the mono-mix scratch buffer on the tap's prepare
572
- // callback so the render thread never allocates. `maxFrames`
573
- // is the tap's per-batch frame budget.
574
- prepare: { frames in processor.tapPrepare(maxFrames: frames) }
685
+ // Configure the cascade and size the mono-mix scratch buffer on the
686
+ // tap's prepare callback, which runs once ahead of the process loop, so
687
+ // the render thread never allocates and never runs against a rate or
688
+ // channel count the tap is not actually delivering.
689
+ prepare: { frames, format in
690
+ processor.setSampleRate(format.mSampleRate)
691
+ processor.setChannelCount(Int(format.mChannelsPerFrame))
692
+ processor.tapPrepare(maxFrames: frames)
693
+ }
575
694
  )
576
695
  }
577
696
 
578
- /// Pull the AudioStreamBasicDescription from a CMFormatDescription
579
- /// without forcing a copy. Returns `nil` if the format isn't a
580
- /// linear-PCM audio format (e.g. compressed source whose
581
- /// formatDescription wraps the encoded format).
582
- private func readASBD(_ format: CMFormatDescription) -> AudioStreamBasicDescription? {
583
- guard let pointer = CMAudioFormatDescriptionGetStreamBasicDescription(format) else {
584
- return nil
585
- }
586
- return pointer.pointee
587
- }
588
697
  }
589
698
 
590
699
  // MARK: - Per-item ReplayGain state
@@ -596,11 +705,37 @@ final class AudioTapProvider: AudioMixProvider {
596
705
  final class ItemRGState {
597
706
 
598
707
  let processor: ReplayGainProcessor
708
+ /// The state is an associated object OF the item, so a strong reference here
709
+ /// would be a cycle.
710
+ weak var item: AVPlayerItem?
711
+ /// `\.status` observation on the owning item, installed by
712
+ /// `AudioTapProvider.attachReplayGainState`. Held here so its lifetime
713
+ /// matches the item's — the item owns this state, so the observation dies
714
+ /// with the thing it observes.
715
+ var statusObservation: NSKeyValueObservation?
599
716
  private let lock = NSLock()
600
717
  private var data: ReplayGainData?
718
+ private var replayGainReadStarted = false
601
719
 
602
- init(processor: ReplayGainProcessor) {
720
+ init(processor: ReplayGainProcessor, item: AVPlayerItem? = nil) {
603
721
  self.processor = processor
722
+ self.item = item
723
+ }
724
+
725
+ deinit {
726
+ statusObservation?.invalidate()
727
+ }
728
+
729
+ /// Claim the one metadata read this state gets. Returns `true` to exactly
730
+ /// one caller; the `\.status` observer and the synchronous bootstrap both
731
+ /// reach a prepared item, and `refreshActiveItemMixes()` can re-run the
732
+ /// bootstrap, so the claim has to be atomic rather than a plain flag check.
733
+ func beginReplayGainRead() -> Bool {
734
+ lock.lock()
735
+ defer { lock.unlock() }
736
+ if replayGainReadStarted { return false }
737
+ replayGainReadStarted = true
738
+ return true
604
739
  }
605
740
 
606
741
  /// Update the cached metadata. The render-thread gain push happens
@@ -164,15 +164,3 @@ final class CarPlayBridge {
164
164
  }
165
165
  }
166
166
 
167
- // `NSLock.withLock` is iOS 16+; the lib's deployment target sits at
168
- // iOS 15.1 (per `min_ios_version_supported`), so a backport is the
169
- // only safe pattern. Drop this when the deployment target advances.
170
- private extension NSLock {
171
- @inline(__always)
172
- func withLock<R>(_ body: () throws -> R) rethrows -> R {
173
- lock()
174
- defer { unlock() }
175
- return try body()
176
- }
177
- }
178
-