react-native-queue-player 1.1.2 → 2.0.0

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 (367) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/build.gradle +7 -0
  4. package/android/consumer-rules.pro +72 -3
  5. package/android/src/main/cpp/airplay2_control.c +4 -4
  6. package/android/src/main/cpp/airplay2_control.h +4 -4
  7. package/android/src/main/cpp/airplay2_jni.cpp +26 -22
  8. package/android/src/main/cpp/airplay2_pair.c +7 -7
  9. package/android/src/main/cpp/airplay2_pair.h +2 -2
  10. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  11. package/android/src/main/cpp/airplay2_session.c +2 -2
  12. package/android/src/main/cpp/airplay_jni.cpp +20 -72
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +870 -278
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +23 -12
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  61. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  62. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  63. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  64. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  65. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +23 -12
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +8 -4
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +163 -0
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  114. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  115. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  116. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  117. package/app.plugin.js +17 -1
  118. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  119. package/ios/AVQueueBuilder.swift +120 -85
  120. package/ios/ArtworkLoader.swift +0 -7
  121. package/ios/ArtworkResolver.swift +22 -4
  122. package/ios/AssetReplayGainReader.swift +118 -0
  123. package/ios/AudioSession.swift +67 -16
  124. package/ios/AudioTapProvider.swift +284 -149
  125. package/ios/CarPlayBridge.swift +0 -12
  126. package/ios/CarPlayCoordinator.swift +18 -2
  127. package/ios/CarPlaySceneDelegate.swift +2 -2
  128. package/ios/Cast/AirPlayRouteState.swift +9 -8
  129. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  130. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  131. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  132. package/ios/Cast/Core/CastSession.swift +11 -0
  133. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  134. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  135. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  136. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  137. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  138. package/ios/CastManager.swift +1 -1
  139. package/ios/CrossfadeEngine.swift +660 -226
  140. package/ios/EQTap.swift +20 -17
  141. package/ios/Equalizer.swift +1 -1
  142. package/ios/FLACStreamInfo.swift +65 -0
  143. package/ios/GaplessEngine.swift +57 -30
  144. package/ios/InputGuards.swift +35 -5
  145. package/ios/LookaheadCache.swift +202 -58
  146. package/ios/LookaheadCachePrefetcher.swift +48 -36
  147. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  148. package/ios/NetworkRecoveryPolicy.swift +32 -0
  149. package/ios/NowPlayingFormatExtractor.swift +25 -18
  150. package/ios/NowPlayingInfo.swift +84 -48
  151. package/ios/OutputRouteMonitor.swift +16 -3
  152. package/ios/PendingBrowseRequests.swift +1 -1
  153. package/ios/PlaceholderArtwork.swift +9 -5
  154. package/ios/PlaybackEngine.swift +165 -76
  155. package/ios/PlaybackErrorMapping.swift +3 -3
  156. package/ios/PlaybackModeStateMachine.swift +2 -2
  157. package/ios/PlaybackNetworkMonitor.swift +73 -0
  158. package/ios/PlayerStateDerivation.swift +6 -6
  159. package/ios/QueueMutationArithmetic.swift +25 -0
  160. package/ios/QueueSkipArithmetic.swift +5 -3
  161. package/ios/QueueWindowArithmetic.swift +152 -0
  162. package/ios/ReadThroughServer.swift +283 -0
  163. package/ios/RemoteCommands.swift +44 -4
  164. package/ios/ReplayGainData.swift +22 -3
  165. package/ios/ReplayGainExtractor.swift +70 -27
  166. package/ios/Siri/VoiceDonation.swift +14 -9
  167. package/ios/StreamingBitrateProbe.swift +22 -68
  168. package/ios/SuppliedReplayGain.swift +83 -0
  169. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  170. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  171. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  172. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  173. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  174. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  175. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  176. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  177. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  178. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  179. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  180. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  181. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  182. package/ios/Tests/EngineSwapTests.swift +91 -0
  183. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  184. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  185. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  186. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  187. package/ios/Tests/InputGuardsTests.swift +41 -0
  188. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  189. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  190. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  191. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  192. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  193. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  194. package/ios/Tests/MutationDeferralTests.swift +220 -0
  195. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  196. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  197. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  198. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  199. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  200. package/ios/Tests/PlayerFixtures.swift +48 -0
  201. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  202. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  203. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  204. package/ios/Tests/QueueStateTests.swift +3 -1
  205. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  206. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  207. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  208. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  209. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  210. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  211. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  212. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  213. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  214. package/ios/Tests/SkipIndexTests.swift +32 -2
  215. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  216. package/ios/Tests/StallRecoveryTests.swift +97 -0
  217. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  218. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  219. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  220. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  221. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  222. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  223. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  224. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  225. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  226. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  227. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  228. package/ios/TrackPlayer+Automotive.swift +101 -0
  229. package/ios/TrackPlayer+Cache.swift +232 -0
  230. package/ios/TrackPlayer+Config.swift +291 -0
  231. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  232. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  233. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  234. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  235. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  236. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  237. package/ios/TrackPlayer+Queue.swift +940 -0
  238. package/ios/TrackPlayer+Recovery.swift +196 -0
  239. package/ios/TrackPlayer+Skip.swift +455 -0
  240. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  241. package/ios/TrackPlayer+State.swift +108 -0
  242. package/ios/TrackPlayer+Threading.swift +135 -0
  243. package/ios/TrackPlayer+Transport.swift +267 -0
  244. package/ios/TrackPlayer+Window.swift +195 -0
  245. package/ios/TrackPlayer.swift +305 -4610
  246. package/ios/Visualizer.swift +6 -5
  247. package/ios/tests-harness/Podfile +1 -1
  248. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  249. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  250. package/lib/module/hooks/useActiveTrack.js +29 -22
  251. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  252. package/lib/module/hooks/useCast.js +8 -26
  253. package/lib/module/hooks/useCast.js.map +1 -1
  254. package/lib/module/hooks/useEqualizer.js +19 -12
  255. package/lib/module/hooks/useEqualizer.js.map +1 -1
  256. package/lib/module/hooks/useLookaheadCache.js +3 -7
  257. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  258. package/lib/module/hooks/useQueue.js +66 -19
  259. package/lib/module/hooks/useQueue.js.map +1 -1
  260. package/lib/module/index.js +6 -3
  261. package/lib/module/index.js.map +1 -1
  262. package/lib/module/queueDelta.js +41 -0
  263. package/lib/module/queueDelta.js.map +1 -0
  264. package/lib/module/types.js +28 -5
  265. package/lib/module/types.js.map +1 -1
  266. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  267. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  269. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useCast.d.ts +6 -1
  271. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  272. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  273. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  274. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  275. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  276. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  277. package/lib/typescript/index.d.ts +2 -1
  278. package/lib/typescript/index.d.ts.map +1 -1
  279. package/lib/typescript/queueDelta.d.ts +10 -0
  280. package/lib/typescript/queueDelta.d.ts.map +1 -0
  281. package/lib/typescript/types.d.ts +168 -12
  282. package/lib/typescript/types.d.ts.map +1 -1
  283. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  284. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  285. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  286. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  287. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  288. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  289. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  290. 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
  291. 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
  292. 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
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  304. 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
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  320. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  321. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  322. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  323. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  324. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  325. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  326. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  327. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  328. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  329. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  330. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  331. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  332. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  333. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  334. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  335. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  336. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  337. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  338. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  339. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  340. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  341. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  342. package/package.json +8 -8
  343. package/src/TrackPlayer.nitro.ts +53 -27
  344. package/src/hooks/useActiveTrack.ts +29 -22
  345. package/src/hooks/useCast.ts +14 -15
  346. package/src/hooks/useEqualizer.ts +19 -12
  347. package/src/hooks/useLookaheadCache.ts +3 -7
  348. package/src/hooks/useQueue.ts +66 -17
  349. package/src/index.ts +13 -3
  350. package/src/queueDelta.ts +41 -0
  351. package/src/types.ts +169 -12
  352. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  353. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
  354. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  355. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  356. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  357. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  358. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  359. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  360. package/ios/MetadataReader.swift +0 -555
  361. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  362. package/ios/Tests/MetadataReaderTests.swift +0 -603
  363. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  364. package/ios/Tests/VoiceDonationTests.swift +0 -26
  365. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  366. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  367. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -4,6 +4,7 @@ import android.content.Context
4
4
  import android.media.AudioFocusRequest
5
5
  import android.media.AudioManager
6
6
  import android.os.Build
7
+ import android.util.Log
7
8
  import androidx.annotation.MainThread
8
9
  import androidx.annotation.RequiresApi
9
10
  import androidx.media3.common.AudioAttributes
@@ -281,24 +282,54 @@ internal class CrossfadeEngine(
281
282
  if (duckActive) userVolume * DUCK_MULTIPLIER else userVolume
282
283
 
283
284
  /**
284
- * Threading: AudioManager dispatches the focus callback on the
285
- * Looper attached to the main thread by default (no Handler
286
- * passed to the builder), so this lands on the same dispatcher
287
- * the engine expects for every other call.
285
+ * Set for the duration of the two `pause()` calls a focus loss makes.
286
+ *
287
+ * This engine sets `handleAudioFocus = false` and owns focus itself, so
288
+ * Media3 never stamps a focus pause with its audio-focus reason: `pause()`
289
+ * dispatches `onPlayWhenReadyChanged(false, USER_REQUEST)` inline, and the
290
+ * delegate stamps whatever reason the last callback carried. While this is
291
+ * set the leading listener reports `AUDIO_FOCUS_LOSS` instead — the reason
292
+ * Media3 would have used, which the delegate maps to an interruption — so a
293
+ * phone call does not arrive as "the user pressed pause". Reporting the
294
+ * loss before the pauses does not work: the state is still PLAYING then, so
295
+ * that report is deduplicated and the pause's own reason overwrites it.
296
+ */
297
+ private var pausingForFocusLoss = false
298
+
299
+ private fun pauseForFocusLoss() {
300
+ pausingForFocusLoss = true
301
+ try {
302
+ playerA.pause()
303
+ playerB.pause()
304
+ } finally {
305
+ pausingForFocusLoss = false
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Threading: AudioManager dispatches the focus callback on the Looper
311
+ * attached to the main thread by default (no Handler passed to the
312
+ * builder), so this lands on the same dispatcher the engine expects for
313
+ * every other call.
288
314
  */
289
315
  private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
290
316
  when (focusChange) {
291
317
  AudioManager.AUDIOFOCUS_LOSS -> {
292
318
  pausedByFocusLoss = false
293
319
  duckActive = false
294
- playerA.pause()
295
- playerB.pause()
320
+ // Cancel before pausing. The fade ramp runs on wall-clock time and is
321
+ // not gated on isPlaying, so pausing the legs alone leaves it running
322
+ // through the interruption: it finishes during the call and swaps
323
+ // roles, and playback resumes on the wrong leg with the session
324
+ // pointing at the other one.
325
+ cancelFade()
326
+ pauseForFocusLoss()
296
327
  }
297
328
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
298
329
  pausedByFocusLoss = leadingPlayer.isPlaying
299
330
  duckActive = false
300
- playerA.pause()
301
- playerB.pause()
331
+ cancelFade()
332
+ pauseForFocusLoss()
302
333
  }
303
334
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
304
335
  // Notification ping over media etc. — Media3's
@@ -348,7 +379,6 @@ internal class CrossfadeEngine(
348
379
  private val leadingPlayerListener = object : Player.Listener {
349
380
  override fun onPlaybackStateChanged(playbackState: Int) {
350
381
  if (released) return
351
- delegate?.onStateMaybeChanged(this@CrossfadeEngine, playWhenReadyReason = null)
352
382
  if (playbackState == Player.STATE_ENDED) {
353
383
  // Leading player ended naturally — if a fade was in flight
354
384
  // it didn't complete in time. Cancel the ramp, snap
@@ -357,13 +387,21 @@ internal class CrossfadeEngine(
357
387
  if (fadeJob?.isActive == true) {
358
388
  cancelFadeAndPromoteStandby()
359
389
  }
390
+ // End signal before the generic state callback, as in GaplessEngine.
360
391
  delegate?.onPlaybackEnded(this@CrossfadeEngine)
361
392
  }
393
+ delegate?.onStateMaybeChanged(this@CrossfadeEngine, playWhenReadyReason = null)
362
394
  }
363
395
 
364
396
  override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
365
397
  if (released) return
366
- delegate?.onStateMaybeChanged(this@CrossfadeEngine, playWhenReadyReason = reason)
398
+ val effectiveReason =
399
+ if (pausingForFocusLoss && !playWhenReady) {
400
+ Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS
401
+ } else {
402
+ reason
403
+ }
404
+ delegate?.onStateMaybeChanged(this@CrossfadeEngine, playWhenReadyReason = effectiveReason)
367
405
  }
368
406
 
369
407
  override fun onIsPlayingChanged(isPlaying: Boolean) {
@@ -374,6 +412,9 @@ internal class CrossfadeEngine(
374
412
 
375
413
  override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
376
414
  if (released) return
415
+ // The leg moved on: a later visit to the index whose preroll failed is
416
+ // a fresh visit (a repeat-all wrap, a skip back) and arms again.
417
+ armFailedForIndex = -1
377
418
  delegate?.onTrackTransition(this@CrossfadeEngine, reason)
378
419
  }
379
420
 
@@ -395,7 +436,22 @@ internal class CrossfadeEngine(
395
436
 
396
437
  override fun onPlayerError(error: PlaybackException) {
397
438
  if (released) return
398
- delegate?.onError(this@CrossfadeEngine, error)
439
+ // A failure mid-fade is the outgoing track failing on its way out, and
440
+ // the incoming one is already prerolled and audible. Promote it, exactly
441
+ // as a leading leg that *ends* mid-fade does above — there is nothing
442
+ // worth retrying about a track that was seconds from being discarded,
443
+ // and rebuilding it would restart it under a ramp that is still running,
444
+ // leaving two legs audible.
445
+ if (fadeJob?.isActive == true) {
446
+ // Logged on the way out: a permanent failure here — a codec or a
447
+ // malformed container — leaves no other trace, and "the audio glitched
448
+ // at a track boundary" is undiagnosable without it.
449
+ Log.w("RNQP.CrossfadeEngine", "leading leg failed mid-fade; promoting standby", error)
450
+ cancelFadeAndPromoteStandby()
451
+ return
452
+ }
453
+ delegate?.onError(
454
+ this@CrossfadeEngine, error, failedIndex = leadingPlayer.currentMediaItemIndex)
399
455
  }
400
456
  }
401
457
 
@@ -419,8 +475,16 @@ internal class CrossfadeEngine(
419
475
  // decide whether to skip / retry; the leading leg keeps
420
476
  // playing.
421
477
  if (released) return
478
+ // Before the cancel: it clears the standby leg's items, and an empty
479
+ // timeline reports the masking index 0 rather than the track that
480
+ // failed — which would spend track 0's retry budget and name its url.
481
+ val failedIndex = standbyPlayer.currentMediaItemIndex
482
+ // The boundary keeps its own disarm marker. `cancelFade` clears
483
+ // `standbyArmed`, so without this the next poll tick re-arms the same
484
+ // failing source and keeps re-prerolling it for the rest of the track.
485
+ armFailedForIndex = leadingPlayer.currentMediaItemIndex
422
486
  cancelFade()
423
- delegate?.onError(this@CrossfadeEngine, error)
487
+ delegate?.onError(this@CrossfadeEngine, error, failedIndex = failedIndex)
424
488
  }
425
489
  }
426
490
 
@@ -507,15 +571,6 @@ internal class CrossfadeEngine(
507
571
 
508
572
  // --- PlaybackEngine: lifecycle ---
509
573
 
510
- override suspend fun prepare(
511
- mediaItems: List<MediaItem>,
512
- startIndex: Int,
513
- startPositionMs: Long,
514
- ) {
515
- leadingPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
516
- leadingPlayer.prepare()
517
- }
518
-
519
574
  override fun release() {
520
575
  if (released) return
521
576
  released = true
@@ -524,7 +579,7 @@ internal class CrossfadeEngine(
524
579
  // state, so a mid-fade revert emit here would surface a
525
580
  // spurious onTrackChange to the OUTGOING leg of a fade that
526
581
  // the consumer no longer cares about.
527
- cancelFade(emitRevert = false)
582
+ cancelFade(emitRevert = false, rebindWrapper = false)
528
583
  stopArmingPoll()
529
584
  audioManager.abandonAudioFocusRequest(focusRequest)
530
585
  // [leadingPlayerListener] / [standbyPlayerListener] are
@@ -573,18 +628,33 @@ internal class CrossfadeEngine(
573
628
  // [standbyArmed] could remain true against an empty / unrelated
574
629
  // queue and [maybeArmCrossfade] would early-return forever.
575
630
  standbyArmed = false
631
+ armFailedForIndex = -1
576
632
  leadingPlayer.setMediaItems(items, startIndex, startPositionMs)
577
633
  leadingPlayer.prepare()
578
634
  standbyPlayer.stop()
579
635
  standbyPlayer.clearMediaItems()
580
636
  standbyPlayer.volume = 0f
581
637
  // Empty queue — no boundaries to arm. Stop the poll until
582
- // play() restarts it.
638
+ // play() restarts it, and park the leg: draining is not pausing, so
639
+ // `playWhenReady` would otherwise carry into the next queue seated here.
583
640
  if (items.isEmpty()) {
584
641
  stopArmingPoll()
642
+ leadingPlayer.pause()
585
643
  }
586
644
  }
587
645
 
646
+ override fun settlePendingFade() {
647
+ // `emitRevert = true`: the caller keeps playing what the leading leg holds
648
+ // rather than seating a new active track, so without the revert the audio
649
+ // returns to the outgoing track while the canonical index still names the
650
+ // incoming one, and no track change follows to correct it.
651
+ cancelFade(emitRevert = true)
652
+ }
653
+
654
+ override fun settlePendingFadeByPromoting() {
655
+ if (fadeJob?.isActive == true) cancelFadeAndPromoteStandby()
656
+ }
657
+
588
658
  override fun addMediaItems(items: List<MediaItem>, insertBefore: Int) {
589
659
  // Queue mutation invalidates the standby leg's pre-loaded
590
660
  // future. Cancel any in-flight fade before mutating the
@@ -593,17 +663,22 @@ internal class CrossfadeEngine(
593
663
  // emitRevert=false: the TrackPlayer mutation body recomputes the
594
664
  // canonical index, so a mid-fade revert here would clobber it.
595
665
  cancelFade(emitRevert = false)
666
+ // The failed-preroll marker is an index; after a mutation it may name a
667
+ // different track.
668
+ armFailedForIndex = -1
596
669
  leadingPlayer.addMediaItems(insertBefore, items)
597
670
  }
598
671
 
599
672
  override fun removeMediaItems(indices: IntArray) {
600
673
  cancelFade(emitRevert = false)
674
+ armFailedForIndex = -1
601
675
  val sorted = indices.toMutableList().apply { sortDescending() }
602
676
  for (i in sorted) leadingPlayer.removeMediaItem(i)
603
677
  }
604
678
 
605
679
  override fun moveMediaItem(fromIndex: Int, toIndex: Int) {
606
680
  cancelFade(emitRevert = false)
681
+ armFailedForIndex = -1
607
682
  leadingPlayer.moveMediaItem(fromIndex, toIndex)
608
683
  }
609
684
 
@@ -826,13 +901,58 @@ internal class CrossfadeEngine(
826
901
  recomputeLegFromConfig(isA = false)
827
902
  }
828
903
 
904
+ /**
905
+ * Resolves the consumer's ReplayGain claim over the queue entry at a
906
+ * given index. Installed by [TrackPlayer] at engine construction and
907
+ * invoked on every read rather than cached: a `setQueue` carrying
908
+ * corrected values then takes effect at the next recompute, with no
909
+ * cache to invalidate at the wrong moment.
910
+ *
911
+ * A `null` return means the consumer supplied nothing for that entry
912
+ * and the file's own tags stand. A non-null result with a `null`
913
+ * [SuppliedReplayGain.data] means the consumer claimed the track and
914
+ * gave nothing usable — a different state, and one the merge has to
915
+ * be able to tell apart.
916
+ */
917
+ internal var suppliedReplayGainResolver: ((Int) -> SuppliedReplayGain?)? = null
918
+
919
+ /**
920
+ * The ReplayGain a leg's current item actually plays with: the
921
+ * consumer's claim where there is one, that leg's cached tags
922
+ * otherwise. Every read of ReplayGain on this engine goes through
923
+ * here, so the merge lands before the first value reaches the audio
924
+ * or a listener.
925
+ *
926
+ * Per leg, because each leg holds its own item and its own processor.
927
+ * The index is that leg's own `currentMediaItemIndex`, which is
928
+ * meaningful for both legs: [armCrossfade] loads the FULL queue onto
929
+ * standby rather than the tail, so both legs count in the same global
930
+ * index space as the consumer-supplied queue.
931
+ */
932
+ private fun effectiveReplayGainData(isA: Boolean): ReplayGainData? {
933
+ val player = if (isA) playerA else playerB
934
+ val cached = if (isA) cachedReplayGainDataA else cachedReplayGainDataB
935
+ // An empty timeline reports index 0, which would name the first
936
+ // queue entry for a leg holding no items at all — the standby leg
937
+ // before it is armed. Nothing is claimed until there is an item for
938
+ // the index to mean something.
939
+ val supplied = if (player.mediaItemCount == 0) null
940
+ else suppliedReplayGainResolver?.invoke(player.currentMediaItemIndex)
941
+ return ReplayGainData.merge(
942
+ suppliedClaimed = supplied != null,
943
+ supplied = supplied?.data,
944
+ detected = cached,
945
+ )
946
+ }
947
+
829
948
  @MainThread
830
949
  private fun recomputeLegFromConfig(isA: Boolean) {
831
950
  val mode = ReplayGainConfig.getMode()
951
+ val gain = computeLinearGain(effectiveReplayGainData(isA), mode)
832
952
  if (isA) {
833
- replayGainProcessorA.setLinearGain(computeLinearGain(cachedReplayGainDataA, mode))
953
+ replayGainProcessorA.setLinearGain(gain)
834
954
  } else {
835
- replayGainProcessorB.setLinearGain(computeLinearGain(cachedReplayGainDataB, mode))
955
+ replayGainProcessorB.setLinearGain(gain)
836
956
  }
837
957
  }
838
958
 
@@ -862,18 +982,19 @@ internal class CrossfadeEngine(
862
982
  }
863
983
 
864
984
  /**
865
- * Snapshot the LEADING leg's cached [ReplayGainData] for the
985
+ * Snapshot the ReplayGain the LEADING leg's item plays with, for the
866
986
  * [NowPlayingFormatExtractor] surface. The active item from JS's
867
987
  * point of view is always the leading leg's current item except
868
988
  * during the fade window — at which point [activePlayer] flips to
869
- * standby and this read should follow. Returns `null` when no
870
- * usable RG tags have been parsed yet for the active leg's track.
989
+ * standby and this read should follow. Returns `null` when nothing is
990
+ * supplied for the active leg's track and no usable RG tags have been
991
+ * parsed for it either.
871
992
  */
872
993
  override fun currentReplayGainData(): ReplayGainData? =
873
- if (activePlayer === playerA) cachedReplayGainDataA else cachedReplayGainDataB
994
+ effectiveReplayGainData(isA = activePlayer === playerA)
874
995
 
875
996
  /**
876
- * `true` when the active leg's cached data carries a tag for the
997
+ * `true` when the active leg's ReplayGain carries a tag for the
877
998
  * current [ReplayGainConfig.getMode]. Used by the NowPlayingFormat
878
999
  * path to populate `replayGainActive`.
879
1000
  */
@@ -932,37 +1053,43 @@ internal class CrossfadeEngine(
932
1053
  return null
933
1054
  }
934
1055
 
935
- // --- PlaybackEngine: handoff ---
936
-
937
- override suspend fun handoff(to: PlaybackEngine, atTrackBoundary: Boolean) {
938
- // Engine handoff (gapless ↔ crossfade swap at TrackPlayer
939
- // level) is wired in the swapEngine path on PlaybackService
940
- // alongside this engine's own boundary detection. The
941
- // crossfade-to-other-engine handoff lives at PlaybackService
942
- // because TrackPlayer holds the canonical queue model that
943
- // the receiving engine needs.
944
- throw NotImplementedError(
945
- "PlaybackEngine.handoff is driven by PlaybackService.swapEngine; do not call directly"
946
- )
947
- }
948
-
949
1056
  // --- Fade orchestration ---
950
1057
 
951
1058
  /**
952
- * Cancel any in-flight fade and snap the leading leg back to
953
- * full volume + drop the standby leg's item. Called from manual
954
- * skip / pause / seek paths and the route-degradation callback.
1059
+ * Cancel any in-flight fade and snap the leading leg back to full volume +
1060
+ * drop the standby leg's item. Called from manual skip / pause / seek paths
1061
+ * and the route-degradation callback. [emitRevert] controls the JS-facing
1062
+ * revert only. Re-binding the MediaSession wrapper is not optional and is
1063
+ * governed by [rebindWrapper], which only [release] clears — see the note on
1064
+ * the revert block below.
955
1065
  */
956
- internal fun cancelFade(emitRevert: Boolean = true) {
1066
+ internal fun cancelFade(emitRevert: Boolean = true, rebindWrapper: Boolean = true) {
957
1067
  val job = fadeJob
958
1068
  val wasMidFade = job?.isActive == true
959
1069
  val leadingIdx = leadingPlayer.currentMediaItemIndex
960
1070
  fadeJob = null
961
1071
  job?.cancel()
1072
+ // Disarm as well as cancel. [standbyArmed] is what stops
1073
+ // [maybeArmCrossfade] arming twice for one track, so leaving it set
1074
+ // after a cancel means it stays set for the rest of the session: every later
1075
+ // arm attempt returns early and each boundary becomes a hard cut, with
1076
+ // nothing surfaced and no way back except a seek, skip or new queue.
1077
+ // Cancelling before the preroll settles leaves it set with no fade job
1078
+ // to clear it, which is the window a pause lands in.
1079
+ standbyArmed = false
962
1080
  leadingPlayer.volume = userVolume
963
1081
  standbyPlayer.volume = 0f
964
1082
  standbyPlayer.stop()
965
1083
  standbyPlayer.clearMediaItems()
1084
+ if (wasMidFade && rebindWrapper) {
1085
+ // Re-bind first and unconditionally. A mid-fade cancel stops and clears
1086
+ // the standby leg, but the fade's start already pointed the session at
1087
+ // it, so leaving the binding alone hands the lock screen, notification,
1088
+ // AVRCP and Android Auto a player with no items — a blank surface and a
1089
+ // dead scrubber while audio continues on the leading leg. This is
1090
+ // independent of whether JS wants a revert event.
1091
+ onCrossfadeCancel?.invoke()
1092
+ }
966
1093
  if (wasMidFade && emitRevert) {
967
1094
  // Mid-fade cancel: fade-start fired `onCrossfadeBegin` →
968
1095
  // delegate moved JS-facing state to the incoming leg. Revert
@@ -976,7 +1103,6 @@ internal class CrossfadeEngine(
976
1103
  // track state, so an intermediate revert here would surface
977
1104
  // a spurious onTrackChange to the OUTGOING leg of a fade
978
1105
  // the consumer no longer cares about).
979
- onCrossfadeCancel?.invoke()
980
1106
  delegate?.onCrossfadeCancel(this, leadingIdx)
981
1107
  }
982
1108
  }
@@ -1057,7 +1183,7 @@ internal class CrossfadeEngine(
1057
1183
  // listener (which forwards onTrackTransition / onPlaybackStateChanged
1058
1184
  // / onIsPlayingChanged / onPlayerError to the engine delegate)
1059
1185
  // fires from the player that's now leading. Without this the
1060
- // listeners stay glued to playerA / playerB and post-swap events
1186
+ // listeners stay attached to playerA / playerB and post-swap events
1061
1187
  // route to the wrong listener, swallowing track transitions on
1062
1188
  // the new leading leg.
1063
1189
  //
@@ -1117,6 +1243,14 @@ internal class CrossfadeEngine(
1117
1243
  armingJob = null
1118
1244
  }
1119
1245
 
1246
+ /**
1247
+ * The leading index whose standby preroll failed. Arming is skipped for
1248
+ * that boundary so one failing source is not re-prerolled on every poll
1249
+ * tick; cleared when the leading leg transitions and on every queue
1250
+ * mutation, since the index is only meaningful for this visit to it.
1251
+ */
1252
+ private var armFailedForIndex: Int = -1
1253
+
1120
1254
  /**
1121
1255
  * Decide whether to arm the standby preroll + run the fade based
1122
1256
  * on the leading leg's current position relative to its
@@ -1125,6 +1259,7 @@ internal class CrossfadeEngine(
1125
1259
  */
1126
1260
  private fun maybeArmCrossfade() {
1127
1261
  if (standbyArmed) return
1262
+ if (leadingPlayer.currentMediaItemIndex == armFailedForIndex) return
1128
1263
  if (fadeJob?.isActive == true) return
1129
1264
  if (!leadingPlayer.isPlaying) return
1130
1265
  if (crossfadeDurationMs <= 0L) return
@@ -1252,8 +1387,8 @@ internal class CrossfadeEngine(
1252
1387
  // forwarding wrapper to the standby `ExoPlayer` so MediaSession's
1253
1388
  // auto-published metadata + scrubber flip to the incoming
1254
1389
  // track from 00:00 the moment it begins audibly playing. Then
1255
- // notify the delegate to fire JS-facing `onTrackChange` +
1256
- // refresh `MediaSessionCompat` extras.
1390
+ // notify the delegate to fire JS-facing `onTrackChange`, refresh the
1391
+ // now-playing format and recompute skip capability.
1257
1392
  onCrossfadeBegin?.invoke()
1258
1393
  delegate?.onCrossfadeBegin(this, incomingIdx)
1259
1394
  }
@@ -7,6 +7,8 @@ import androidx.media3.common.util.UnstableApi
7
7
  import com.facebook.proguard.annotations.DoNotStrip
8
8
  import com.margelo.nitro.NitroModules
9
9
  import com.margelo.nitro.core.Promise
10
+ import kotlinx.coroutines.Dispatchers
11
+ import kotlinx.coroutines.withContext
10
12
 
11
13
  /**
12
14
  * 10-band parametric equalizer HybridObject. Picks
@@ -182,7 +184,11 @@ class Equalizer @JvmOverloads constructor(
182
184
  internal fun setEnabledInternal(enabled: Boolean) {
183
185
  if (engine.isEnabled() == enabled) return
184
186
  engine.setEnabled(enabled)
185
- onEnabledChangeListeners.forEach { it(enabled) }
187
+ // Emit what the engine committed, not what was asked for: a released
188
+ // engine drops the call, and `onEnabledChange` registration replays
189
+ // `engine.isEnabled()`, so emitting the request here would hand
190
+ // subscribers a value `isEnabled()` contradicts.
191
+ onEnabledChangeListeners.forEach { it(engine.isEnabled()) }
186
192
  }
187
193
 
188
194
  internal fun setBandGainInternal(index: Int, gainDb: Float) {
@@ -233,26 +239,36 @@ class Equalizer @JvmOverloads constructor(
233
239
  }
234
240
 
235
241
  // --- JS-facing async surface ---
242
+ //
243
+ // Every mutation of the engine runs on the main looper: `attachAll` is
244
+ // driven there by the playback engine's session-id listener, and running
245
+ // the setters on the `Promise.async` pool alongside it let a setter iterate
246
+ // an instance the looper had just released, two setters interleave their
247
+ // band writes, or a setter miss an instance an attach was inserting. One
248
+ // executor for all of it closes those.
236
249
 
237
250
  override fun setEnabled(enabled: Boolean): Promise<Unit> =
238
- Promise.async { setEnabledInternal(enabled) }
251
+ Promise.async { withContext(Dispatchers.Main.immediate) { setEnabledInternal(enabled) } }
239
252
 
240
253
  override fun isEnabled(): Boolean = engine.isEnabled()
241
254
 
242
255
  override fun getBands(): Array<EqualizerBand> = currentBandsSnapshot()
243
256
 
244
257
  override fun setBandGain(index: Double, gainDb: Double): Promise<Unit> =
245
- Promise.async { setBandGainInternal(index.toInt(), gainDb.toFloat()) }
258
+ Promise.async {
259
+ withContext(Dispatchers.Main.immediate) { setBandGainInternal(index.toInt(), gainDb.toFloat()) }
260
+ }
246
261
 
247
262
  override fun setAllBandGains(gains: DoubleArray): Promise<Unit> =
248
263
  Promise.async {
249
264
  val asFloats = FloatArray(gains.size) { i -> gains[i].toFloat() }
250
- setAllBandGainsInternal(asFloats)
265
+ withContext(Dispatchers.Main.immediate) { setAllBandGainsInternal(asFloats) }
251
266
  }
252
267
 
253
268
  override fun applyPreset(name: String): Promise<Unit> =
254
269
  Promise.async {
255
- if (!applyPresetInternal(name)) {
270
+ val applied = withContext(Dispatchers.Main.immediate) { applyPresetInternal(name) }
271
+ if (!applied) {
256
272
  throw IllegalArgumentException("Equalizer preset not found: $name")
257
273
  }
258
274
  }
@@ -288,13 +304,17 @@ class Equalizer @JvmOverloads constructor(
288
304
  if (EqualizerPresets.getByName(name) != null) {
289
305
  throw IllegalArgumentException("Cannot overwrite built-in preset: $name")
290
306
  }
291
- saveCustomPresetInternal(name)
307
+ // The gains are read on the looper that writes them; the `commit` to
308
+ // preferences stays off it.
309
+ val gains = withContext(Dispatchers.Main.immediate) { engine.getBandGains() }
310
+ customPresetStore?.save(name, gains)
292
311
  }
293
312
 
294
313
  override fun deleteCustomPreset(name: String): Promise<Unit> =
295
314
  Promise.async { deleteCustomPresetInternal(name) }
296
315
 
297
- override fun reset(): Promise<Unit> = Promise.async { resetInternal() }
316
+ override fun reset(): Promise<Unit> =
317
+ Promise.async { withContext(Dispatchers.Main.immediate) { resetInternal() } }
298
318
 
299
319
  override fun onBandChange(
300
320
  callback: (bands: Array<EqualizerBand>) -> Unit
@@ -5,6 +5,7 @@ import android.os.Build
5
5
  import android.util.Log
6
6
  import androidx.annotation.RequiresApi
7
7
  import androidx.media3.common.C
8
+ import java.util.concurrent.ConcurrentHashMap
8
9
 
9
10
  /**
10
11
  * 10-band pre-EQ for API 28+ backed by [DynamicsProcessing].
@@ -29,11 +30,16 @@ import androidx.media3.common.C
29
30
  * [attach] `channelCount` parameter is therefore ignored on this
30
31
  * backend — retained on the interface for future per-track engines.
31
32
  *
32
- * Threading: callers serialize externally — [attach] / [detach] /
33
- * [setBandGain] / [setEnabled] run from the playback engine's
34
- * audio-session-id listener executor, which delivers events single-
35
- * threaded. The [DynamicsProcessing] instance is not guarded by an
36
- * internal lock; cross-thread mutation is the caller's concern.
33
+ * Threading: not serialised. [attachAll] / [detachAll] run on the
34
+ * service main looper from the audio-session-id listener, and on the
35
+ * thread constructing an [Equalizer] (its init wires the engine, and
36
+ * the listener registration replays the current session ids
37
+ * synchronously). [setBandGain] / [setAllBandGains] / [setEnabled] run
38
+ * on the `Promise.async` pool, so two JS calls may run concurrently;
39
+ * [isEnabled] / [getBandGains] run synchronously on the JS thread.
40
+ * `instances` is a `ConcurrentHashMap` and the flags are `@Volatile`,
41
+ * which rules out structural corruption and stale reads. What remains
42
+ * is documented on the fields.
37
43
  * [release] briefly blocks while the native effect tears down (a
38
44
  * few ms typical, longer on slow devices) — callers may dispatch
39
45
  * off-thread if a teardown burst risks a UI hitch.
@@ -54,16 +60,26 @@ class EqualizerEngine : IEqualizerEngine {
54
60
  private const val LOG_TAG = "EqualizerEngine"
55
61
  }
56
62
 
63
+ // Written by the setters and by attach, all on the main looper:
64
+ // `Equalizer` hops every JS-facing mutation there, and the playback
65
+ // engine's session-id listener drives attach there. Nothing touches
66
+ // this off the looper.
57
67
  private val gainsDb: FloatArray = FloatArray(BAND_COUNT)
58
- private var enabled: Boolean = false
68
+ @Volatile private var enabled: Boolean = false
59
69
  /**
60
70
  * One [DynamicsProcessing] instance per attached audio session.
61
71
  * Crossfade-window callers can hold two simultaneously
62
72
  * (outgoing + incoming legs) so EQ continuity is preserved
63
73
  * across the fade. Single-session callers see exactly one entry.
74
+ * Iteration order is never relied on: the attach diff is set
75
+ * arithmetic and every other loop applies one operation per entry.
76
+ * Inserts use `putIfAbsent` and removals go through `remove`, so two
77
+ * attaches racing on one session release the loser instead of
78
+ * leaking it, and a detach cannot drop an instance inserted between
79
+ * its iteration and its clear.
64
80
  */
65
- private val instances: MutableMap<Int, DynamicsProcessing> = LinkedHashMap()
66
- private var released: Boolean = false
81
+ private val instances: MutableMap<Int, DynamicsProcessing> = ConcurrentHashMap()
82
+ @Volatile private var released: Boolean = false
67
83
 
68
84
  /**
69
85
  * Diff [sessionIds] against the current per-session map:
@@ -104,6 +120,7 @@ class EqualizerEngine : IEqualizerEngine {
104
120
  results[id] = true
105
121
  continue
106
122
  }
123
+ var instance: DynamicsProcessing? = null
107
124
  results[id] = try {
108
125
  val config = DynamicsProcessing.Config.Builder(
109
126
  DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
@@ -113,13 +130,18 @@ class EqualizerEngine : IEqualizerEngine {
113
130
  /* postEqInUse = */ false, /* postEqBandCount = */ 0,
114
131
  /* limiterInUse = */ false,
115
132
  ).build()
116
- val instance = DynamicsProcessing(PRIORITY, id, config)
117
- instances[id] = instance
133
+ instance = DynamicsProcessing(PRIORITY, id, config)
118
134
  applyAllBandsToInstance(instance)
119
135
  instance.setEnabled(enabled)
136
+ // Insert only once the instance is fully configured, so a throw
137
+ // above leaves no entry for isAttached() to report. A concurrent
138
+ // attach that got here first keeps its instance; ours is released.
139
+ val existing = instances.putIfAbsent(id, instance)
140
+ if (existing != null) instance.release()
120
141
  true
121
142
  } catch (e: RuntimeException) {
122
143
  Log.w(LOG_TAG, "DynamicsProcessing attach failed for session $id", e)
144
+ instance?.release()
123
145
  false
124
146
  }
125
147
  }
@@ -131,8 +153,7 @@ class EqualizerEngine : IEqualizerEngine {
131
153
  * gains + enabled state survive for the next [attachAll].
132
154
  */
133
155
  override fun detachAll() {
134
- for (instance in instances.values) instance.release()
135
- instances.clear()
156
+ for (id in instances.keys) instances.remove(id)?.release()
136
157
  }
137
158
 
138
159
  /** True when the engine is attached to at least one audio session. */