react-native-queue-player 1.2.0 → 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 (355) 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 +103 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +839 -271
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  58. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  59. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  60. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  61. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  109. package/app.plugin.js +17 -1
  110. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  111. package/ios/AVQueueBuilder.swift +120 -85
  112. package/ios/ArtworkLoader.swift +0 -7
  113. package/ios/ArtworkResolver.swift +22 -4
  114. package/ios/AssetReplayGainReader.swift +118 -0
  115. package/ios/AudioSession.swift +67 -16
  116. package/ios/AudioTapProvider.swift +284 -149
  117. package/ios/CarPlayBridge.swift +0 -12
  118. package/ios/CarPlayCoordinator.swift +18 -2
  119. package/ios/CarPlaySceneDelegate.swift +2 -2
  120. package/ios/Cast/AirPlayRouteState.swift +9 -8
  121. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  122. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  123. package/ios/Cast/Core/CastSession.swift +2 -2
  124. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  125. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  126. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  127. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  128. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  129. package/ios/CastManager.swift +1 -1
  130. package/ios/CrossfadeEngine.swift +660 -226
  131. package/ios/EQTap.swift +20 -17
  132. package/ios/Equalizer.swift +1 -1
  133. package/ios/FLACStreamInfo.swift +65 -0
  134. package/ios/GaplessEngine.swift +57 -30
  135. package/ios/InputGuards.swift +35 -5
  136. package/ios/LookaheadCache.swift +202 -58
  137. package/ios/LookaheadCachePrefetcher.swift +48 -36
  138. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  139. package/ios/NetworkRecoveryPolicy.swift +32 -0
  140. package/ios/NowPlayingFormatExtractor.swift +25 -18
  141. package/ios/NowPlayingInfo.swift +84 -48
  142. package/ios/OutputRouteMonitor.swift +16 -3
  143. package/ios/PendingBrowseRequests.swift +1 -1
  144. package/ios/PlaceholderArtwork.swift +9 -5
  145. package/ios/PlaybackEngine.swift +165 -76
  146. package/ios/PlaybackErrorMapping.swift +3 -3
  147. package/ios/PlaybackModeStateMachine.swift +2 -2
  148. package/ios/PlaybackNetworkMonitor.swift +73 -0
  149. package/ios/PlayerStateDerivation.swift +6 -6
  150. package/ios/QueueMutationArithmetic.swift +25 -0
  151. package/ios/QueueSkipArithmetic.swift +5 -3
  152. package/ios/QueueWindowArithmetic.swift +152 -0
  153. package/ios/ReadThroughServer.swift +283 -0
  154. package/ios/RemoteCommands.swift +44 -4
  155. package/ios/ReplayGainData.swift +22 -3
  156. package/ios/ReplayGainExtractor.swift +70 -27
  157. package/ios/Siri/VoiceDonation.swift +14 -9
  158. package/ios/StreamingBitrateProbe.swift +22 -68
  159. package/ios/SuppliedReplayGain.swift +83 -0
  160. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  161. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  162. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  163. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  164. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  165. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  166. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  167. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  168. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  169. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  170. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  171. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  172. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  173. package/ios/Tests/EngineSwapTests.swift +91 -0
  174. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  175. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  176. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  177. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  178. package/ios/Tests/InputGuardsTests.swift +41 -0
  179. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  180. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  181. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  182. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  183. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  184. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  185. package/ios/Tests/MutationDeferralTests.swift +220 -0
  186. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  187. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  188. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  189. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  190. package/ios/Tests/PlayerFixtures.swift +48 -0
  191. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  192. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  193. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  194. package/ios/Tests/QueueStateTests.swift +3 -1
  195. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  196. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  197. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  198. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  199. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  200. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  201. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  202. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  203. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  204. package/ios/Tests/SkipIndexTests.swift +32 -2
  205. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  206. package/ios/Tests/StallRecoveryTests.swift +97 -0
  207. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  208. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  209. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  210. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  211. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  212. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  213. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  214. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  215. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  216. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  217. package/ios/TrackPlayer+Automotive.swift +101 -0
  218. package/ios/TrackPlayer+Cache.swift +232 -0
  219. package/ios/TrackPlayer+Config.swift +291 -0
  220. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  221. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  222. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  223. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  224. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  225. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  226. package/ios/TrackPlayer+Queue.swift +940 -0
  227. package/ios/TrackPlayer+Recovery.swift +196 -0
  228. package/ios/TrackPlayer+Skip.swift +455 -0
  229. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  230. package/ios/TrackPlayer+State.swift +108 -0
  231. package/ios/TrackPlayer+Threading.swift +135 -0
  232. package/ios/TrackPlayer+Transport.swift +267 -0
  233. package/ios/TrackPlayer+Window.swift +195 -0
  234. package/ios/TrackPlayer.swift +305 -4619
  235. package/ios/Visualizer.swift +6 -5
  236. package/ios/tests-harness/Podfile +1 -1
  237. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  238. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  239. package/lib/module/hooks/useActiveTrack.js +29 -22
  240. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  241. package/lib/module/hooks/useCast.js +8 -26
  242. package/lib/module/hooks/useCast.js.map +1 -1
  243. package/lib/module/hooks/useEqualizer.js +19 -12
  244. package/lib/module/hooks/useEqualizer.js.map +1 -1
  245. package/lib/module/hooks/useLookaheadCache.js +3 -7
  246. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  247. package/lib/module/hooks/useQueue.js +66 -19
  248. package/lib/module/hooks/useQueue.js.map +1 -1
  249. package/lib/module/index.js +6 -3
  250. package/lib/module/index.js.map +1 -1
  251. package/lib/module/queueDelta.js +41 -0
  252. package/lib/module/queueDelta.js.map +1 -0
  253. package/lib/module/types.js +28 -5
  254. package/lib/module/types.js.map +1 -1
  255. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  256. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  257. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  258. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  259. package/lib/typescript/hooks/useCast.d.ts +6 -1
  260. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  261. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  262. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  263. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  265. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  266. package/lib/typescript/index.d.ts +2 -1
  267. package/lib/typescript/index.d.ts.map +1 -1
  268. package/lib/typescript/queueDelta.d.ts +10 -0
  269. package/lib/typescript/queueDelta.d.ts.map +1 -0
  270. package/lib/typescript/types.d.ts +168 -12
  271. package/lib/typescript/types.d.ts.map +1 -1
  272. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  273. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  274. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  275. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  276. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  277. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  278. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  279. 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
  280. 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
  281. 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
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  293. 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
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  313. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  314. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  315. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  316. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  317. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  318. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  319. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  320. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  321. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  322. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  323. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  324. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  325. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  326. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  327. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  328. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  329. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  330. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  331. package/package.json +8 -8
  332. package/src/TrackPlayer.nitro.ts +53 -27
  333. package/src/hooks/useActiveTrack.ts +29 -22
  334. package/src/hooks/useCast.ts +14 -15
  335. package/src/hooks/useEqualizer.ts +19 -12
  336. package/src/hooks/useLookaheadCache.ts +3 -7
  337. package/src/hooks/useQueue.ts +66 -17
  338. package/src/index.ts +13 -3
  339. package/src/queueDelta.ts +41 -0
  340. package/src/types.ts +169 -12
  341. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  342. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  343. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  344. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  345. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  346. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  347. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  348. package/ios/MetadataReader.swift +0 -555
  349. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  350. package/ios/Tests/MetadataReaderTests.swift +0 -603
  351. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  352. package/ios/Tests/VoiceDonationTests.swift +0 -26
  353. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  354. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  355. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -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. */
@@ -3,6 +3,7 @@ package com.margelo.nitro.queueplayer
3
3
  import android.media.audiofx.Equalizer
4
4
  import android.util.Log
5
5
  import androidx.media3.common.C
6
+ import java.util.concurrent.ConcurrentHashMap
6
7
  import kotlin.math.abs
7
8
  import kotlin.math.roundToInt
8
9
 
@@ -27,7 +28,16 @@ import kotlin.math.roundToInt
27
28
  * also clamped to the device's reported `getBandLevelRange()` to
28
29
  * avoid IllegalArgumentException on aggressive devices.
29
30
  *
30
- * Threading: methods are called from the service main looper.
31
+ * Threading: not serialised. [attachAll] / [detachAll] run on the
32
+ * service main looper from the audio-session-id listener, and on the
33
+ * thread constructing an [Equalizer] (its init wires the engine, and
34
+ * the listener registration replays the current session ids
35
+ * synchronously). [setBandGain] / [setAllBandGains] / [setEnabled] run
36
+ * on the `Promise.async` pool, so two JS calls may run concurrently;
37
+ * [isEnabled] / [getBandGains] run synchronously on the JS thread.
38
+ * `instances` is a `ConcurrentHashMap` and the flags are `@Volatile`,
39
+ * which rules out structural corruption and stale reads. What remains
40
+ * is documented on the fields.
31
41
  * [release] briefly blocks while the native effect tears down.
32
42
  */
33
43
  class EqualizerLegacyEngine : IEqualizerEngine {
@@ -70,7 +80,7 @@ class EqualizerLegacyEngine : IEqualizerEngine {
70
80
 
71
81
  /**
72
82
  * Per-session equalizer instance + the per-device-derived
73
- * mapping & gain bounds latched at attach time. Each session
83
+ * mapping & gain bounds read once at attach time. Each session
74
84
  * derives independently so AOSP-fork variance across sessions
75
85
  * is handled correctly.
76
86
  */
@@ -81,10 +91,19 @@ class EqualizerLegacyEngine : IEqualizerEngine {
81
91
  val deviceMaxMb: Short,
82
92
  )
83
93
 
94
+ // Written by the setters and by attach, all on the main looper:
95
+ // `Equalizer` hops every JS-facing mutation there, and the playback
96
+ // engine's session-id listener drives attach there. Nothing touches
97
+ // this off the looper.
84
98
  private val gainsDb: FloatArray = FloatArray(BAND_COUNT)
85
- private var enabled: Boolean = false
86
- private val instances: MutableMap<Int, Slot> = LinkedHashMap()
87
- private var released: Boolean = false
99
+ @Volatile private var enabled: Boolean = false
100
+ // Iteration order is never relied on: the attach diff is set arithmetic
101
+ // and every other loop applies one operation per entry. Inserts use
102
+ // `putIfAbsent` and removals go through `remove`, so two attaches
103
+ // racing on one session release the loser instead of leaking it, and a
104
+ // detach cannot drop a slot inserted between its iteration and clear.
105
+ private val instances: MutableMap<Int, Slot> = ConcurrentHashMap()
106
+ @Volatile private var released: Boolean = false
88
107
 
89
108
  /**
90
109
  * Diff [sessionIds] against the current per-session map.
@@ -138,9 +157,14 @@ class EqualizerLegacyEngine : IEqualizerEngine {
138
157
  deviceMinMb = range[0],
139
158
  deviceMaxMb = range[1],
140
159
  )
141
- instances[audioSessionId] = slot
142
160
  applyAllBandsToSlot(slot)
143
161
  instance.enabled = enabled
162
+ // Insert only once the slot is fully configured, so a throw above
163
+ // leaves no entry for isAttached() to report against a released
164
+ // native handle. A concurrent attach that got here first keeps its
165
+ // slot; ours is released.
166
+ val existing = instances.putIfAbsent(audioSessionId, slot)
167
+ if (existing != null) instance.release()
144
168
  true
145
169
  } catch (e: RuntimeException) {
146
170
  Log.w(LOG_TAG, "Equalizer attach failed for session $audioSessionId", e)
@@ -150,8 +174,7 @@ class EqualizerLegacyEngine : IEqualizerEngine {
150
174
  }
151
175
 
152
176
  override fun detachAll() {
153
- for (slot in instances.values) slot.equalizer.release()
154
- instances.clear()
177
+ for (id in instances.keys) instances.remove(id)?.equalizer?.release()
155
178
  }
156
179
 
157
180
  override fun isAttached(): Boolean = instances.isNotEmpty()
@@ -38,10 +38,9 @@ internal class FFTProcessorTee(
38
38
  }
39
39
 
40
40
  // 2× ring so we can copy the latest fftSize-sample window without
41
- // worrying about mid-window wraparound. AudioBufferSink callbacks
42
- // are synchronous on the audio render thread — single-writer,
43
- // single-reader, no synchronization needed. Length is a power of
44
- // two so wrap is a free bitmask.
41
+ // worrying about mid-window wraparound. handleBuffer is the only
42
+ // writer and reader of the ring, and it runs on the audio render
43
+ // thread. Length is a power of two so wrap is a free bitmask.
45
44
  private val ring = FloatArray(fftSize * 2)
46
45
  private val ringMask: Int = ring.size - 1
47
46
  private var writeIdx = 0L
@@ -57,21 +56,33 @@ internal class FFTProcessorTee(
57
56
  private val magsOut = FloatArray(fftSize / 2)
58
57
  private val samplesOut: FloatArray? = if (includeSamples) FloatArray(fftSize) else null
59
58
 
60
- private var sampleRate: Int = 44100
61
- private var channelCount: Int = 2
62
- private var encoding: Int = C.ENCODING_PCM_16BIT
63
- private var bytesPerFrame: Int = 4 // 2 channels × 2 bytes
64
- private var bytesPerSampleCached: Int = 2
59
+ /**
60
+ * The stream format a buffer is decoded under. Immutable and published
61
+ * as a whole through the single [format] reference, so a decode never
62
+ * mixes one flush's channel count with another's encoding.
63
+ */
64
+ private class Format(val sampleRate: Int, val channelCount: Int, val encoding: Int) {
65
+ val bytesPerSample: Int = bytesPerSample(encoding)
66
+ val bytesPerFrame: Int = bytesPerSample * channelCount
67
+ }
65
68
 
69
+ // flush() runs on whichever thread attaches the sink (see
70
+ // MultiplexingAudioBufferSink.primeNewSink) and on the render thread;
71
+ // handleBuffer runs on the render thread and reads this once per call.
72
+ @Volatile private var format = Format(44100, 2, C.ENCODING_PCM_16BIT)
73
+
74
+ // `writeIdx` (above) and `lastEmitNs` are written by handleBuffer and
75
+ // reset by flush, with no ordering between the two. A flush racing a
76
+ // handleBuffer can lose the `writeIdx` reset, so exactly one emitted
77
+ // frame carries up to fftSize pre-flush samples — comparable to the
78
+ // delayed first frame MultiplexingAudioBufferSink documents for a
79
+ // double flush. A lost `lastEmitNs` reset delays the first post-flush
80
+ // frame by at most intervalMs. On a 32-bit ABI the Long resets can
81
+ // tear; the outcomes are the same one frame.
66
82
  private var lastEmitNs: Long = 0L
67
83
 
68
84
  override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) {
69
- this.sampleRate = sampleRateHz
70
- this.channelCount = channelCount.coerceAtLeast(1)
71
- this.encoding = encoding
72
- val sampleSize = bytesPerSample(encoding)
73
- this.bytesPerSampleCached = sampleSize
74
- this.bytesPerFrame = sampleSize * this.channelCount
85
+ format = Format(sampleRateHz, channelCount.coerceAtLeast(1), encoding)
75
86
  // Reset write index on stream change so the ring doesn't carry
76
87
  // samples from the previous track / sample-rate config across.
77
88
  writeIdx = 0L
@@ -79,17 +90,19 @@ internal class FFTProcessorTee(
79
90
  }
80
91
 
81
92
  override fun handleBuffer(buffer: ByteBuffer) {
82
- if (bytesPerFrame == 0 || bytesPerSampleCached == 0) return // unsupported encoding
93
+ // One read: every field this call uses comes from the same flush.
94
+ val fmt = format
95
+ if (fmt.bytesPerFrame == 0 || fmt.bytesPerSample == 0) return // unsupported encoding
83
96
  // Read via a duplicated view so the source buffer's position +
84
97
  // byte order are not mutated. Media3 documents the input as a
85
98
  // duplicated read-only buffer, but local-mutating it would
86
99
  // surprise any future consumer that relied on the contract.
87
100
  val view = buffer.duplicate().order(ByteOrder.nativeOrder())
88
- val frameCount = view.remaining() / bytesPerFrame
101
+ val frameCount = view.remaining() / fmt.bytesPerFrame
89
102
  var idxLo = (writeIdx and ringMask.toLong()).toInt()
90
103
  var i = 0
91
104
  while (i < frameCount) {
92
- ring[idxLo] = readMonoSample(view)
105
+ ring[idxLo] = readMonoSample(view, fmt)
93
106
  idxLo = (idxLo + 1) and ringMask
94
107
  i++
95
108
  }
@@ -100,10 +113,10 @@ internal class FFTProcessorTee(
100
113
  if (nowNs - lastEmitNs < intervalMs * 1_000_000L) return
101
114
  lastEmitNs = nowNs
102
115
 
103
- emitFrame()
116
+ emitFrame(fmt.sampleRate)
104
117
  }
105
118
 
106
- private fun emitFrame() {
119
+ private fun emitFrame(sampleRate: Int) {
107
120
  // Latest fftSize samples ending at writeIdx - 1.
108
121
  val startLo = ((writeIdx - fftSize) and ringMask.toLong()).toInt()
109
122
  var i = 0
@@ -134,24 +147,24 @@ internal class FFTProcessorTee(
134
147
  * a sample (returns 0); the audio chain doesn't typically deliver
135
148
  * those to AudioSink.
136
149
  */
137
- private fun readMonoSample(buffer: ByteBuffer): Float {
150
+ private fun readMonoSample(buffer: ByteBuffer, fmt: Format): Float {
138
151
  var sum = 0f
139
152
  var ch = 0
140
- while (ch < channelCount) {
141
- sum += when (encoding) {
153
+ while (ch < fmt.channelCount) {
154
+ sum += when (fmt.encoding) {
142
155
  C.ENCODING_PCM_16BIT -> buffer.short.toFloat() / Short.MAX_VALUE.toFloat()
143
156
  C.ENCODING_PCM_FLOAT -> buffer.float
144
157
  C.ENCODING_PCM_32BIT -> buffer.int.toFloat() / Int.MAX_VALUE.toFloat()
145
158
  C.ENCODING_PCM_24BIT -> read24BitPacked(buffer)
146
159
  else -> {
147
160
  // Skip unknown encoding: advance position by sample size guess.
148
- buffer.position(buffer.position() + bytesPerSample(encoding))
161
+ buffer.position(buffer.position() + fmt.bytesPerSample)
149
162
  0f
150
163
  }
151
164
  }
152
165
  ch++
153
166
  }
154
- return sum / channelCount
167
+ return sum / fmt.channelCount
155
168
  }
156
169
 
157
170
  private fun read24BitPacked(buffer: ByteBuffer): Float {
@@ -164,10 +177,12 @@ internal class FFTProcessorTee(
164
177
  return v.toFloat() / 8_388_608f // 2^23
165
178
  }
166
179
 
167
- private fun bytesPerSample(encoding: Int): Int = when (encoding) {
168
- C.ENCODING_PCM_16BIT -> 2
169
- C.ENCODING_PCM_24BIT -> 3
170
- C.ENCODING_PCM_32BIT, C.ENCODING_PCM_FLOAT -> 4
171
- else -> 0
180
+ private companion object {
181
+ fun bytesPerSample(encoding: Int): Int = when (encoding) {
182
+ C.ENCODING_PCM_16BIT -> 2
183
+ C.ENCODING_PCM_24BIT -> 3
184
+ C.ENCODING_PCM_32BIT, C.ENCODING_PCM_FLOAT -> 4
185
+ else -> 0
186
+ }
172
187
  }
173
188
  }
@@ -26,17 +26,48 @@ import java.util.TreeSet
26
26
  * only to force the recency-bump rewrite.)
27
27
  *
28
28
  * Callbacks are invoked under the [Cache]'s lock, so the plain fields below need
29
- * no additional synchronization — mirrors Media3's own evictor.
29
+ * no additional synchronization — mirrors Media3's own evictor. [protectedKeys]
30
+ * is the exception: it is written from outside those callbacks.
31
+ *
32
+ * [setProtectedKeys] names urls eviction must leave alone. Only this evictor
33
+ * honours them — Media3's LRU evictor has no such concept, and LRU is the
34
+ * default policy, so protection ships off unless a consumer selects FIFO. LRU
35
+ * is not equivalent: it bumps the recency of the span just read, not the whole
36
+ * resource, so a long track's already-played spans stay evictable while it is
37
+ * still playing.
30
38
  */
31
39
  @UnstableApi
32
40
  class FifoCacheEvictor(private val maxBytes: Long) : CacheEvictor {
33
41
 
34
42
  private var insertionCounter: Long = 0
43
+
44
+ /**
45
+ * Bytes held by the spans this evictor tracks — not the cache's on-disk total.
46
+ *
47
+ * The two differ when the cache notifies an add whose span [fifo] rejects: the
48
+ * bytes land on disk but no removal is ever notified for them, so counting
49
+ * them here would push the evictor permanently over budget and make it evict
50
+ * everything on every add. Untracked bytes are reclaimed when their resource
51
+ * is removed as a whole.
52
+ */
35
53
  private var currentSize: Long = 0
36
54
 
37
55
  /** Insertion order per resource key (the track URL); stamped once, never bumped. */
38
56
  private val insertionOrderByKey = HashMap<String, Long>()
39
57
 
58
+ /**
59
+ * Live span count per resource key, maintained from the [fifo] mutations
60
+ * themselves rather than from the callback count.
61
+ *
62
+ * [CacheSpan] defines no `equals`/`hashCode`, and [Cache] notifies an add even
63
+ * when the set rejects it as a duplicate, so a count derived from callbacks
64
+ * drifts from the set. A drifted count drops an [insertionOrderByKey] entry
65
+ * while spans for that key are still live, which changes the comparator's
66
+ * verdict for elements already in [fifo]; removals then silently fail and
67
+ * [evictToFit] cannot make progress.
68
+ */
69
+ private val spanCountByKey = HashMap<String, Int>()
70
+
40
71
  /** Spans ordered oldest-first by (resource insertion order, byte position). */
41
72
  private val fifo = TreeSet<CacheSpan> { a, b ->
42
73
  val oa = insertionOrderByKey[a.key] ?: Long.MAX_VALUE
@@ -48,6 +79,26 @@ class FifoCacheEvictor(private val maxBytes: Long) : CacheEvictor {
48
79
  }
49
80
  }
50
81
 
82
+ /**
83
+ * Urls eviction must not take. Written from the main thread while callbacks
84
+ * run under the [Cache] lock on whichever thread touched the cache, hence
85
+ * volatile. The setter copies because a Kotlin `Set` is read-only rather than
86
+ * immutable: without the copy a caller could keep mutating the set it handed
87
+ * over and change what is protected behind this evictor's back.
88
+ */
89
+ @Volatile
90
+ private var protectedKeys: Set<String> = emptySet()
91
+
92
+ /**
93
+ * Name the urls eviction must leave alone — the playing track and the ones
94
+ * around it. Without this, FIFO evicts the track being played the moment the
95
+ * cache is over budget: [requiresCacheSpanTouches] is false, so a read never
96
+ * bumps a resource's order and the playing track cannot protect itself.
97
+ */
98
+ fun setProtectedKeys(keys: Set<String>) {
99
+ protectedKeys = keys.toSet()
100
+ }
101
+
51
102
  override fun requiresCacheSpanTouches(): Boolean = false
52
103
 
53
104
  override fun onCacheInitialized() {
@@ -61,36 +112,81 @@ class FifoCacheEvictor(private val maxBytes: Long) : CacheEvictor {
61
112
  }
62
113
 
63
114
  override fun onSpanAdded(cache: Cache, span: CacheSpan) {
115
+ // Stamp the order before inserting: the comparator reads it for every
116
+ // element it touches during the insert.
64
117
  insertionOrderByKey.getOrPut(span.key) { insertionCounter++ }
65
- fifo.add(span)
66
- currentSize += span.length
118
+ if (fifo.add(span)) {
119
+ spanCountByKey.merge(span.key, 1, Int::plus)
120
+ currentSize += span.length
121
+ }
67
122
  evictToFit(cache, 0)
68
123
  }
69
124
 
70
125
  override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
71
- fifo.remove(span)
126
+ if (!fifo.remove(span)) return
72
127
  currentSize -= span.length
73
- // Once a resource's last span is gone, forget its insertion order so a
74
- // later re-fetch of the same URL is treated as newly inserted.
75
- if (fifo.none { it.key == span.key }) {
76
- insertionOrderByKey.remove(span.key)
128
+ spanCountByKey.merge(span.key, -1, Int::plus)
129
+ retireIfEmpty(span.key)
130
+ }
131
+
132
+ /**
133
+ * Forget a resource's insertion order once it holds no spans, so a later
134
+ * re-fetch of the same URL is treated as newly inserted.
135
+ *
136
+ * Retires on `<= 0` rather than `== 0`: `HashMap.merge` stores the value
137
+ * directly for an absent key, so a decrement that outruns an increment lands
138
+ * at -1, and an equality test would pin the entry in place permanently.
139
+ */
140
+ private fun retireIfEmpty(key: String) {
141
+ if ((spanCountByKey[key] ?: 0) <= 0) {
142
+ spanCountByKey.remove(key)
143
+ insertionOrderByKey.remove(key)
77
144
  }
78
145
  }
79
146
 
80
147
  override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
81
148
  // Not called while requiresCacheSpanTouches() is false. Handled defensively:
82
- // swap the span in place, preserving the resource's insertion order.
83
- fifo.remove(oldSpan)
84
- currentSize -= oldSpan.length
85
- fifo.add(newSpan)
86
- currentSize += newSpan.length
149
+ // swap the span in place, preserving the resource's insertion order
150
+ // re-stamping it here would move the resource to the back of the queue on a
151
+ // mere access. The count tracks each mutation separately so a swap that
152
+ // only half-applies cannot retire an order whose spans are still live.
153
+ if (fifo.remove(oldSpan)) {
154
+ currentSize -= oldSpan.length
155
+ spanCountByKey.merge(oldSpan.key, -1, Int::plus)
156
+ }
157
+ insertionOrderByKey.getOrPut(newSpan.key) { insertionCounter++ }
158
+ if (fifo.add(newSpan)) {
159
+ currentSize += newSpan.length
160
+ spanCountByKey.merge(newSpan.key, 1, Int::plus)
161
+ }
162
+ // Settle both keys only after both mutations: a same-key swap dips to zero
163
+ // in between, and retiring there would re-stamp the resource as newest.
164
+ retireIfEmpty(oldSpan.key)
165
+ retireIfEmpty(newSpan.key)
87
166
  }
88
167
 
89
168
  private fun evictToFit(cache: Cache, requiredSpace: Long) {
90
- while (currentSize + requiredSpace > maxBytes && fifo.isNotEmpty()) {
169
+ // One volatile read, so the whole scan sees a single consistent set.
170
+ val shielded = protectedKeys
171
+ while (currentSize + requiredSpace > maxBytes) {
172
+ // Oldest first, skipping what must survive. Nothing evictable left means
173
+ // over budget is the outcome — the alternative is spinning here.
174
+ val victim = fifo.firstOrNull { it.key !in shielded } ?: return
91
175
  // removeSpan synchronously calls back onSpanRemoved, which updates
92
176
  // currentSize + fifo, so the loop re-checks against the new total.
93
- cache.removeSpan(fifo.first())
177
+ cache.removeSpan(victim)
178
+ if (fifo.contains(victim)) {
179
+ // The cache index no longer holds this span, so removeSpan notified
180
+ // nothing. Drop our own record: its bytes are unreclaimable, but
181
+ // leaving it in place would make it the victim of every future
182
+ // eviction and stop the cache evicting at all. Membership, not the
183
+ // head, is the test — a protected span can sit ahead of the victim.
184
+ // Membership stays exact because a span still in the tree always has a
185
+ // live order entry: the entry is retired only after its last successful
186
+ // removal. Once retired, the span sorts at the Long.MAX_VALUE sentinel,
187
+ // past every live element, so `contains` reports false.
188
+ onSpanRemoved(cache, victim)
189
+ }
94
190
  }
95
191
  }
96
192
  }
@@ -70,9 +70,10 @@ internal class GaplessEngine(
70
70
  /**
71
71
  * Per-engine ReplayGain audio processor. Always present in the
72
72
  * pipeline; the processor's own [ReplayGainAudioProcessor.isActive]
73
- * gates the per-sample multiply on encoding (Float32) + gain
74
- * (`!= 1.0f`). The [Player.Listener.onTracksChanged] listener
75
- * reads container-level tags out of the active audio track's
73
+ * gates only on encoding, so a gain pushed mid-stream reaches the
74
+ * audio without a sink reconfigure. The
75
+ * [Player.Listener.onTracksChanged] listener reads container-level
76
+ * tags out of the active audio track's
76
77
  * `Format.metadata` and pushes a fresh gain;
77
78
  * [Player.Listener.onMediaItemTransition] resets to unity until
78
79
  * the next track's tracks become available. The
@@ -135,10 +136,13 @@ internal class GaplessEngine(
135
136
 
136
137
  private val playerListener = object : Player.Listener {
137
138
  override fun onPlaybackStateChanged(playbackState: Int) {
138
- delegate?.onStateMaybeChanged(this@GaplessEngine, playWhenReadyReason = null)
139
+ // The end signal goes first: the queue-end path emits ENDED with its
140
+ // own reason, and a plain state emit before it would take that slot
141
+ // with SYSTEM and leave the queue-end emit deduplicated.
139
142
  if (playbackState == Player.STATE_ENDED) {
140
143
  delegate?.onPlaybackEnded(this@GaplessEngine)
141
144
  }
145
+ delegate?.onStateMaybeChanged(this@GaplessEngine, playWhenReadyReason = null)
142
146
  }
143
147
 
144
148
  override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
@@ -199,7 +203,7 @@ internal class GaplessEngine(
199
203
  }
200
204
 
201
205
  override fun onPlayerError(error: PlaybackException) {
202
- delegate?.onError(this@GaplessEngine, error)
206
+ delegate?.onError(this@GaplessEngine, error, failedIndex = exoPlayer.currentMediaItemIndex)
203
207
  }
204
208
  }
205
209
 
@@ -225,15 +229,6 @@ internal class GaplessEngine(
225
229
 
226
230
  // --- PlaybackEngine — lifecycle ---
227
231
 
228
- override suspend fun prepare(
229
- mediaItems: List<MediaItem>,
230
- startIndex: Int,
231
- startPositionMs: Long,
232
- ) {
233
- exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
234
- exoPlayer.prepare()
235
- }
236
-
237
232
  override fun release() {
238
233
  if (released) return
239
234
  released = true
@@ -257,6 +252,11 @@ internal class GaplessEngine(
257
252
  startIndex: Int,
258
253
  startPositionMs: Long,
259
254
  ) {
255
+ if (items.isEmpty()) {
256
+ // Draining is not pausing: `playWhenReady` survives an empty playlist,
257
+ // so the next queue seated on this player would start on contact.
258
+ exoPlayer.pause()
259
+ }
260
260
  exoPlayer.setMediaItems(items, startIndex, startPositionMs)
261
261
  // ExoPlayer is in STATE_IDLE after construction + after stop().
262
262
  // setMediaItems alone doesn't transition out of IDLE — prepare()
@@ -425,12 +425,47 @@ internal class GaplessEngine(
425
425
  // --- ReplayGain — engine-internal accessors ---
426
426
 
427
427
  /**
428
- * Recompute the processor's linear gain from [cachedReplayGainData]
428
+ * Resolves the consumer's ReplayGain claim over the queue entry at a
429
+ * given index. Installed by [TrackPlayer] at engine construction and
430
+ * invoked on every read rather than cached: a `setQueue` carrying
431
+ * corrected values then takes effect at the next recompute, with no
432
+ * cache to invalidate at the wrong moment.
433
+ *
434
+ * A `null` return means the consumer supplied nothing for that entry
435
+ * and the file's own tags stand. A non-null result with a `null`
436
+ * [SuppliedReplayGain.data] means the consumer claimed the track and
437
+ * gave nothing usable — a different state, and one the merge has to
438
+ * be able to tell apart.
439
+ */
440
+ internal var suppliedReplayGainResolver: ((Int) -> SuppliedReplayGain?)? = null
441
+
442
+ /**
443
+ * The ReplayGain the active item actually plays with: the consumer's
444
+ * claim where there is one, [cachedReplayGainData] otherwise. Every
445
+ * read of ReplayGain on this engine goes through here, so the merge
446
+ * lands before the first value reaches the audio or a listener.
447
+ */
448
+ private fun effectiveReplayGainData(): ReplayGainData? {
449
+ // An empty timeline reports index 0, which would name the first
450
+ // queue entry for a player holding no items at all — so nothing is
451
+ // claimed until there is an item for the index to mean something.
452
+ val supplied = if (exoPlayer.mediaItemCount == 0) null
453
+ else suppliedReplayGainResolver?.invoke(exoPlayer.currentMediaItemIndex)
454
+ return ReplayGainData.merge(
455
+ suppliedClaimed = supplied != null,
456
+ supplied = supplied?.data,
457
+ detected = cachedReplayGainData,
458
+ )
459
+ }
460
+
461
+ /**
462
+ * Recompute the processor's linear gain from [effectiveReplayGainData]
429
463
  * + the current [ReplayGainConfig.getMode]. Called from
430
464
  * [Player.Listener.onTracksChanged] / [Player.Listener.onMetadata]
431
465
  * via [handleReplayGainParse] when fresh tags arrive and from
432
466
  * [TrackPlayer.setReplayGainMode] when the user flips mode
433
- * mid-track. With no cached data the processor stays at unity.
467
+ * mid-track. With nothing supplied and nothing cached the processor
468
+ * stays at unity.
434
469
  *
435
470
  * Main thread only — the listener bodies dispatch on the
436
471
  * application looper and [TrackPlayer.setReplayGainMode] hops to
@@ -438,7 +473,7 @@ internal class GaplessEngine(
438
473
  */
439
474
  @MainThread
440
475
  internal fun recomputeReplayGainFromConfig() {
441
- val gain = computeLinearGain(cachedReplayGainData, ReplayGainConfig.getMode())
476
+ val gain = computeLinearGain(effectiveReplayGainData(), ReplayGainConfig.getMode())
442
477
  replayGainProcessor.setLinearGain(gain)
443
478
  }
444
479
 
@@ -465,20 +500,21 @@ internal class GaplessEngine(
465
500
  }
466
501
 
467
502
  /**
468
- * Snapshot the active item's cached [ReplayGainData] for the
469
- * [NowPlayingFormatExtractor] surface. Returns `null` when no
470
- * usable RG tags have been parsed yet for the current track.
503
+ * Snapshot the ReplayGain the active item plays with, for the
504
+ * [NowPlayingFormatExtractor] surface. Returns `null` when nothing is
505
+ * supplied for the current track and no usable RG tags have been
506
+ * parsed for it either.
471
507
  */
472
- override fun currentReplayGainData(): ReplayGainData? = cachedReplayGainData
508
+ override fun currentReplayGainData(): ReplayGainData? = effectiveReplayGainData()
473
509
 
474
510
  /**
475
- * `true` when [cachedReplayGainData] carries a tag for the active
476
- * mode (i.e. the gain push lands at non-unity). Used by the
511
+ * `true` when the active item's ReplayGain carries a tag for the
512
+ * active mode (i.e. the gain push lands at non-unity). Used by the
477
513
  * NowPlayingFormat path to populate `replayGainActive` without
478
514
  * recomputing the linear gain itself.
479
515
  */
480
516
  override fun currentReplayGainActive(): Boolean {
481
- val data = cachedReplayGainData ?: return false
517
+ val data = effectiveReplayGainData() ?: return false
482
518
  val (gainDb, _) = data.selectGain(ReplayGainConfig.getMode())
483
519
  return gainDb != null
484
520
  }
@@ -532,15 +568,6 @@ internal class GaplessEngine(
532
568
  }
533
569
  return null
534
570
  }
535
-
536
- // --- PlaybackEngine — handoff ---
537
-
538
- override suspend fun handoff(to: PlaybackEngine, atTrackBoundary: Boolean) {
539
- // Wired alongside CrossfadeEngine; with only the gapless engine
540
- // available there is no swap target.
541
- throw NotImplementedError("PlaybackEngine.handoff is not yet implemented")
542
- }
543
-
544
571
  }
545
572
 
546
573
  /**