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
@@ -19,6 +19,8 @@ import androidx.media3.common.MediaItem
19
19
  import androidx.media3.common.PlaybackException
20
20
  import androidx.media3.common.Player
21
21
  import androidx.media3.common.util.UnstableApi
22
+ import androidx.media3.database.StandaloneDatabaseProvider
23
+ import androidx.media3.datasource.cache.SimpleCache
22
24
  import com.facebook.react.ReactApplication
23
25
  import androidx.media3.exoplayer.ExoPlayer
24
26
  import com.facebook.proguard.annotations.DoNotStrip
@@ -26,6 +28,8 @@ import com.margelo.nitro.NitroModules
26
28
  import com.margelo.nitro.core.Promise
27
29
  import java.util.concurrent.CountDownLatch
28
30
  import kotlinx.coroutines.CompletableDeferred
31
+ import kotlinx.coroutines.Job
32
+ import kotlinx.coroutines.launch
29
33
  import kotlinx.coroutines.sync.Mutex
30
34
  import kotlinx.coroutines.sync.withLock
31
35
  import kotlinx.coroutines.withTimeoutOrNull
@@ -86,9 +90,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
86
90
  * an on-disk LRU. Built with [LookaheadCache.DEFAULT_MAX_SIZE_BYTES]
87
91
  * by default; `setLookaheadCache` lets consumers disable / resize.
88
92
  *
89
- * Read on main only (configure / destroy run there); no @Volatile
90
- * needed.
93
+ * Written on main (configure / destroy); read off main by
94
+ * `buildCacheStatusSnapshot` from the `Promise.async` pool
95
+ * (`getLookaheadCacheStatus`) and the cache-status IO dispatcher
96
+ * (`emitCacheStatus` / `onCacheStatusChange`), hence @Volatile.
91
97
  */
98
+ @Volatile
92
99
  @VisibleForTesting
93
100
  internal var lookaheadCache: LookaheadCache? = null
94
101
  private set
@@ -100,22 +107,35 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
100
107
  * mutation. Default lookahead window = [LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT]
101
108
  * (3); [setLookaheadCache] runtime config can override.
102
109
  */
110
+ @Volatile
103
111
  @VisibleForTesting
104
112
  internal var lookaheadCacheWriter: LookaheadCacheWriter? = null
105
113
  private set
106
114
 
107
115
  /**
108
- * Live lookahead cache config initialised from
109
- * [LookaheadCacheConfigDefaults] on first configure, mutated by
110
- * [setLookaheadCache]. Read on main only (config + status
111
- * methods always hop). Persists across destroy → configure so a
112
- * consumer who set custom values once doesn't lose them on a
113
- * reconfigure cycle.
116
+ * Watches the default network's transport so prefetch can stop the moment
117
+ * the device hands off to cellular while `allowsCellularAccess` is off.
118
+ * Built with the lookahead stack and released with it.
114
119
  */
115
120
  @VisibleForTesting
121
+ internal var cellularTransportMonitor: CellularTransportMonitor? = null
122
+ private set
123
+
124
+ /**
125
+ * Live lookahead cache config — enabled with
126
+ * [LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT] until
127
+ * [setLookaheadCache] mutates it, on main; read on main by the config
128
+ * paths and off it by [buildCacheStatusSnapshot] on the `Promise.async`
129
+ * pool and the cache-status dispatcher, which is why it is `@Volatile`.
130
+ * Persists across destroy → configure so a consumer who set custom
131
+ * values once doesn't lose them on a reconfigure cycle.
132
+ */
133
+ @Volatile
134
+ @VisibleForTesting
116
135
  internal var lookaheadConfig: LookaheadCacheConfig = LookaheadCacheConfig(
117
136
  enabled = true,
118
- lookaheadCount = LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT.toDouble()
137
+ lookaheadCount = LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT.toDouble(),
138
+ allowsCellularAccess = true
119
139
  )
120
140
 
121
141
  /** Configure-time cache disk budget in MB — `PlayerConfig.lookaheadCacheMaxSizeMb`
@@ -131,6 +151,130 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
131
151
  private fun evictionPolicyFrom(cfg: PlayerConfig): EvictionPolicy =
132
152
  cfg.lookaheadCacheEvictionPolicy ?: EvictionPolicy.LRU
133
153
 
154
+ private fun cachePrefs(context: Context) =
155
+ context.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE)
156
+
157
+ /**
158
+ * The eviction policy the cache on disk was last built with, or null before
159
+ * the first configure. Read from disk because the in-memory config starts at
160
+ * the default in every fresh process, which cannot distinguish "unchanged"
161
+ * from "changed back to the default".
162
+ */
163
+ private fun persistedEvictionPolicy(context: Context): EvictionPolicy? =
164
+ // The live cache wins while the process holds one: it is what the policy
165
+ // describes, and the prefs write is asynchronous, so a quick reconfigure
166
+ // would otherwise read a stale value and wipe a correct cache.
167
+ LookaheadCache.appliedEvictionPolicy(defaultCacheDirectory(context))
168
+ ?: cachePrefs(context).getString(KEY_EVICTION_POLICY, null)
169
+ ?.let { name -> EvictionPolicy.entries.firstOrNull { it.name == name } }
170
+
171
+ private fun defaultCacheDirectory(context: Context) =
172
+ java.io.File(context.applicationContext.cacheDir, LookaheadCache.DEFAULT_CACHE_DIR_NAME)
173
+
174
+ /**
175
+ * `commit()`, not `apply()`: this has to survive a process kill. A queued
176
+ * write lost to one leaves the previous policy recorded, and the next launch
177
+ * reads that as a policy change and wipes a cache that is already correct.
178
+ */
179
+ private fun persistEvictionPolicy(context: Context, policy: EvictionPolicy) {
180
+ val appCtx = context.applicationContext
181
+ cacheIoScope.launch {
182
+ runCatching {
183
+ val prefs = cachePrefs(appCtx)
184
+ if (prefs.getString(KEY_EVICTION_POLICY, null) != policy.name) {
185
+ prefs.edit().putString(KEY_EVICTION_POLICY, policy.name).commit()
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Drop the on-disk cache: rename it out of the way on the caller's thread,
193
+ * then delete the files and the index tables off it.
194
+ *
195
+ * The rename is the load-bearing part. `configureInternal` rebuilds a
196
+ * `SimpleCache` in this same directory a few statements later, and
197
+ * `SimpleCache.initialize` reads the directory as it starts; a delete still
198
+ * running underneath makes that read fail and the cache throws for the rest
199
+ * of its life. Renaming is atomic within a filesystem, so the rebuild sees a
200
+ * clean path immediately and the slow part happens where nothing is watching.
201
+ */
202
+ private fun discardCacheDirectory(context: Context): Job? {
203
+ val appCtx = context.applicationContext
204
+ val cacheDir = defaultCacheDirectory(appCtx)
205
+ // A cache built by a previous process has no shared instance to release, so
206
+ // open a provider for it — the index tables are keyed by cache uid and
207
+ // outlive the files, and deleting the directory alone strands one per
208
+ // policy change.
209
+ val provider = LookaheadCache.releaseSharedInstance(cacheDir)
210
+ ?: runCatching { StandaloneDatabaseProvider(appCtx) }.getOrNull()
211
+ val doomed = java.io.File(
212
+ appCtx.cacheDir,
213
+ "${LookaheadCache.DEFAULT_CACHE_DIR_NAME}$DELETING_DIR_SUFFIX${System.nanoTime()}"
214
+ )
215
+ // Claimed before the rename, so the sweep — which runs off the looper and
216
+ // may list the directory at any moment — never collects a directory this
217
+ // process is deleting: otherwise it would match the name the rename just
218
+ // produced and start a second delete of the same tree, with a second
219
+ // database provider dropping the same index tables.
220
+ claimDoomedDirectory(doomed)
221
+ if (!cacheDir.exists() || !cacheDir.renameTo(doomed)) {
222
+ releaseDoomedDirectory(doomed)
223
+ // Without the rename the directory is still the one the rebuild is about
224
+ // to use, so the delete has to finish before this returns. Doing it off
225
+ // thread here would race the rebuild and can drop the *new* cache's index
226
+ // tables, which is worse than the main-thread cost of a path that only
227
+ // fires when a same-filesystem rename fails.
228
+ deleteCacheDirectory(cacheDir, provider)
229
+ return null
230
+ }
231
+ return cacheIoScope.launch {
232
+ try {
233
+ deleteCacheDirectory(doomed, provider)
234
+ } finally {
235
+ releaseDoomedDirectory(doomed)
236
+ }
237
+ }
238
+ }
239
+
240
+ private fun deleteCacheDirectory(
241
+ dir: java.io.File,
242
+ provider: StandaloneDatabaseProvider?
243
+ ) {
244
+ runCatching {
245
+ // Reads the uid from inside `dir`, so it drops the index tables belonging
246
+ // to this cache and no other.
247
+ if (provider != null) SimpleCache.delete(dir, provider) else dir.deleteRecursively()
248
+ }
249
+ runCatching { provider?.close() }
250
+ }
251
+
252
+ /**
253
+ * Delete directories left behind by a discard that did not finish — a process
254
+ * death between the rename and the delete strands one, and nothing else would
255
+ * ever collect it.
256
+ */
257
+ private fun sweepAbandonedCacheDirectories(context: Context) {
258
+ val appCtx = context.applicationContext
259
+ val prefix = "${LookaheadCache.DEFAULT_CACHE_DIR_NAME}$DELETING_DIR_SUFFIX"
260
+ // Listing and deleting both run off the looper; a directory this process
261
+ // is deleting is claimed before it is renamed into this name pattern, so
262
+ // the listing cannot pick one up mid-discard.
263
+ cacheIoScope.launch {
264
+ val orphans = appCtx.cacheDir.listFiles()
265
+ ?.filter {
266
+ it.isDirectory && it.name.startsWith(prefix) && !isDoomedDirectoryClaimed(it)
267
+ }
268
+ ?: return@launch
269
+ if (orphans.isEmpty()) return@launch
270
+ // Each orphan still carries its own `.uid`, so route it through the same
271
+ // delete to take its index tables with it.
272
+ val provider = runCatching { StandaloneDatabaseProvider(appCtx) }.getOrNull()
273
+ orphans.forEach { runCatching { if (provider != null) SimpleCache.delete(it, provider) else it.deleteRecursively() } }
274
+ runCatching { provider?.close() }
275
+ }
276
+ }
277
+
134
278
  /**
135
279
  * Cache-status listeners registered via [onCacheStatusChange].
136
280
  * Multi-listener registry — every JS consumer that subscribes gets
@@ -198,6 +342,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
198
342
  networkTimeoutMs = null,
199
343
  audioContentType = null,
200
344
  audioCategoryOptions = null,
345
+ // iOS-only opt-in; Android has no in-app vocabulary donation API and
346
+ // ignores it, as `donateVoiceVocabulary` itself does.
347
+ voiceVocabularyDonationEnabled = null,
201
348
  visualizationEnabled = null,
202
349
  clampSeekToBuffered = null,
203
350
  lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
@@ -248,29 +395,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
248
395
  private var playbackModeState: PlaybackModeStateMachine.State =
249
396
  PlaybackModeStateMachine.Initial
250
397
 
251
- /**
252
- * Subscribe to cast-route activate/deactivate transitions.
253
- * Push-PCM sessions (AirPlay 1/2) force-degrade the active engine
254
- * from Crossfade to Gapless because the wire protocol cannot mix
255
- * two ExoPlayers into one sink; clearing the session re-applies
256
- * the user's requested mode (so crossfade re-instates).
257
- *
258
- * The disposer is intentionally discarded — TrackPlayer is a
259
- * process-singleton `HybridObject`; the listener lives for the
260
- * lifetime of the JVM and the disposer would only matter if a
261
- * second TrackPlayer instance ever existed.
262
- */
263
- @Suppress("unused")
264
- private val castRouteDisposer: () -> Unit =
265
- com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
266
- // Re-apply against the user's requested mode on every flip.
267
- // `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
268
- // for the degrade decision, so the swap settles to the right
269
- // engine for the new route state.
270
- val mode = playbackModeState.active
271
- mainHandler.post { applyEngineSwapForMode(mode) }
272
- }
273
-
274
398
  /**
275
399
  * Bridges Chromecast `RemotePlayer` session events back to the
276
400
  * lib's existing JS event surface (`onStateChange` etc.). Lives for
@@ -278,7 +402,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
278
402
  * `PlaybackStateRouter.activeFlow` and subscribes / unsubscribes to
279
403
  * the active session's StateFlows on every transition.
280
404
  */
281
- @Suppress("unused")
282
405
  private val castEventBridge = com.margelo.nitro.queueplayer.cast.CastEventBridge(this).also {
283
406
  it.start()
284
407
  }
@@ -304,6 +427,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
304
427
  kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
305
428
  )
306
429
 
430
+ /**
431
+ * Scope for cache file work that must not run on the app looper — recursive
432
+ * directory deletes and the SQLite index drop that goes with them.
433
+ *
434
+ * Process-lifetime and deliberately not cancelled on destroy: a delete that
435
+ * outlives a `destroy()` still has to finish, or the renamed directory it is
436
+ * clearing leaks until the next launch sweeps it.
437
+ */
438
+ private val cacheIoScope =
439
+ kotlinx.coroutines.CoroutineScope(
440
+ kotlinx.coroutines.SupervisorJob() +
441
+ kotlinx.coroutines.Dispatchers.IO +
442
+ // Cache housekeeping must never take the process down. Off the main
443
+ // thread there is no JS call to surface a failure to, and the default
444
+ // handler treats an uncaught one as fatal.
445
+ kotlinx.coroutines.CoroutineExceptionHandler { _, t ->
446
+ Log.w(TAG, "cache maintenance failed", t)
447
+ }
448
+ )
449
+
450
+ /**
451
+ * Cache-status snapshots, built one at a time.
452
+ *
453
+ * `onTrackProcessed` fires one of these per prefetched track, and on the
454
+ * unbounded IO dispatcher two builds can finish in either order and deliver
455
+ * out of sequence — a subscriber would watch `tracksFullyCached` go
456
+ * backwards. Serialising the builds keeps the deliveries in the order the
457
+ * snapshots were taken.
458
+ */
459
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
460
+ private val cacheStatusDispatcher =
461
+ kotlinx.coroutines.Dispatchers.IO.limitedParallelism(1)
462
+
307
463
  /**
308
464
  * Connection to the in-process [PlaybackService]. `onServiceConnected`
309
465
  * stashes the [PlaybackService.LocalBinder] and completes
@@ -324,13 +480,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
324
480
  // landed; the next disconnect starts the backoff fresh.
325
481
  rebindAttempt = 0
326
482
  rebindHandler.removeCallbacks(rebindRunnable)
327
- // Apply any browse-callback registrations the consumer made
328
- // before the binder was available typically `bootstrap.ts`
329
- // `installCarService` `registerPlaybackService` running
330
- // at bundle-load time, before `configure()` binds the service.
331
- // Without this drain, those pre-bind registrations are dropped
332
- // and Android Auto's later bind times out waiting for the JS
333
- // carConnect callback.
483
+ // Apply browse-callback registrations made while [serviceBinder]
484
+ // was null — before the first bind, or between a service kill and
485
+ // this rebind. Without the drain those registrations never reach
486
+ // the service and `dispatchCarConnect` times out waiting for a JS
487
+ // carConnect callback that was never installed.
334
488
  drainPendingBrowseRegistrations()
335
489
  drainPendingSnapshot()
336
490
  // Re-apply the remote-control config to the (possibly reborn) service.
@@ -544,7 +698,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
544
698
  // simultaneously without clobbering each other.
545
699
  private val stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Unit>()
546
700
  private val trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Unit>()
547
- private val queueChangeListeners = ListenerRegistry<(Array<TrackItem>, Double, QueueChangeReason) -> Unit>()
701
+ private val queueChangeListeners = ListenerRegistry<(QueueChangeDelta, Double, QueueChangeReason) -> Unit>()
548
702
  private val progressListeners = ListenerRegistry<(PlayerProgress) -> Unit>()
549
703
  // Buffer state — discrete empty/buffering/stalled/full for the active track, derived
550
704
  // from Media3's playback state + buffered-ahead. Recomputed on the engine
@@ -585,17 +739,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
585
739
  private var progressLifecycleObserver: DefaultLifecycleObserver? = null
586
740
  private val errorListeners = ListenerRegistry<(PlaybackError) -> Unit>()
587
741
  private val queueEndListeners = ListenerRegistry<() -> Unit>()
588
- // SkipCapability struct wraps (canSkipPrevious, canSkipNext).
589
- // Struct rather than separate boolean args dodges a Nitrogen
590
- // 0.35.4 (bool, bool) callback codegen bug.
742
+ // Workaround: the callback carries a `SkipCapability` struct rather than
743
+ // two boolean arguments, because Nitrogen miscompiles a `(bool, bool) ->
744
+ // void` callback. No margelo/nitro issue found for it.
591
745
  private val skipCapabilityListeners = ListenerRegistry<(SkipCapability) -> Unit>()
592
746
 
593
- /**
594
- * Last-emitted skip capability tuple. Native owns the truth; the
595
- * JS layer is a passive observer. Recomputed via
596
- * [recomputeCapabilities] at every `tracks` / `currentTrackIndex`
597
- * / `repeatModeState` write site, with dedup.
598
- */
599
747
  /**
600
748
  * Authoritative snapshot of (canSkipPrevious, canSkipNext) — single
601
749
  * `@Volatile` reference write keeps the pair atomic across cross-
@@ -643,12 +791,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
643
791
  private var gaplessGapPrevEndedAtNs: Long = 0L
644
792
 
645
793
  /**
646
- * Debug-only timestamps backing the verbose `RNQP-GAPLESS` Log.d
647
- * emits. Wrapped in `if (BuildConfig.DEBUG)` at every read/write
648
- * site so R8 strips the branches in Release builds. Field storage
649
- * stays (16 bytes total — negligible) but is never accessed in prod.
794
+ * Debug-only timestamp backing the verbose `RNQP-GAPLESS` Log.d emits.
795
+ * Every read and write sits inside `if (BuildConfig.DEBUG)`, so R8
796
+ * strips the branches in Release builds; only the field itself remains.
650
797
  */
651
- private var gaplessLogLastEndedAtNs: Long = 0L
652
798
  private var gaplessLogLastChangedAtNs: Long = 0L
653
799
 
654
800
  /** Stashed by transport methods before calling player.play()/pause()/stop()/seekTo().
@@ -700,9 +846,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
700
846
  * transient-error fire; when 0, the error surfaces to JS.
701
847
  * Re-populated on `setQueueInternal` /
702
848
  * `addToQueueInternal` from `effectiveAutoRetries()` (clamped
703
- * 0..5; default 3). Reset to 1 (single shot) by `play()` if state
704
- * is `.error` and the counter is 0 stuck-recovery on user-
705
- * initiated re-tap. Cleared on `removeFromQueue` / `moveInQueue`
849
+ * 0..5; default 1). Restored to the configured value by `retry()`, so
850
+ * a manual attempt gives whatever fails next its configured automatic
851
+ * allowance. `play()` restores the same configured value when the counter
852
+ * is 0 — stuck-recovery on a user re-tap. Cleared on `removeFromQueue` / `moveInQueue`
706
853
  * (index shifts invalidate keys; budget resets are an acceptable
707
854
  * trade-off vs walking + remapping the map per shifted index). */
708
855
  private var retryAttemptsRemaining: MutableMap<Int, Int> = mutableMapOf()
@@ -874,25 +1021,34 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
874
1021
  internal fun configureInternal(newConfig: PlayerConfig, context: Context) {
875
1022
  // Eviction policy is fixed at SimpleCache construction; a change clears the
876
1023
  // cache + rebuilds under the new evictor (infrequent — see FifoCacheEvictor).
877
- // Safe to release the shared SimpleCache here: configure() runs
878
- // destroyInternal() before this, which releases the prior engine — the only
879
- // CacheDataSource reader of that cache so there is no live reader when the
880
- // shared instance is released and its directory deleted below.
881
- val previousEvictionPolicy = evictionPolicyFrom(this.config)
1024
+ // The service still holds the engine `configurePlayer` replaces further
1025
+ // down — `destroyInternal` drops this player's references, not the
1026
+ // service's engine and that engine reads through the shared cache, so it
1027
+ // is stopped before the cache is released and its directory renamed away.
1028
+ // Compare against what was last APPLIED, not against the in-memory config.
1029
+ // A fresh process starts at the LRU default, so a FIFO consumer would
1030
+ // otherwise read every cold launch as a policy change and wipe its cache
1031
+ // before it could ever be used.
1032
+ val previousEvictionPolicy =
1033
+ persistedEvictionPolicy(context) ?: evictionPolicyFrom(this.config)
882
1034
  this.config = newConfig
883
1035
  // Swap the cover-art placeholder bitmap to the consumer-supplied image
884
1036
  // (or back to the built-in when unset). The no-art metadata URI is
885
1037
  // threaded separately at each buildMediaItems call.
886
1038
  PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
887
1039
  if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
1040
+ serviceBinder?.engine?.stop()
888
1041
  lookaheadCacheWriter?.release()
889
1042
  lookaheadCacheWriter = null
890
1043
  lookaheadCache?.release()
891
1044
  lookaheadCache = null
892
- val cacheDir = java.io.File(context.cacheDir, LookaheadCache.DEFAULT_CACHE_DIR_NAME)
893
- LookaheadCache.releaseSharedInstance(cacheDir)
894
- cacheDir.deleteRecursively()
1045
+ cellularTransportMonitor?.stop()
1046
+ cellularTransportMonitor = null
1047
+ discardCacheDirectory(context)
895
1048
  }
1049
+ // Collect what a previous process left mid-delete. Directories this
1050
+ // process is still deleting are claimed and skipped.
1051
+ sweepAbandonedCacheDirectories(context)
896
1052
  // Seed the progress-emission throttle from config and start observing app
897
1053
  // background/foreground. Both are idempotent, so a re-configure refreshes
898
1054
  // the rate without double-registering the lifecycle observer.
@@ -907,6 +1063,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
907
1063
  // new field.
908
1064
  VisualizationGate.setEnabled(newConfig.visualizationEnabled ?: true)
909
1065
  buildLookaheadStack(newConfig, context)
1066
+ // Recorded only once a cache actually exists under this policy, so a failed
1067
+ // build cannot leave a policy stamped that nothing was built with.
1068
+ if (lookaheadCache != null) {
1069
+ persistEvictionPolicy(context, evictionPolicyFrom(newConfig))
1070
+ }
1071
+ // configure() destroys first, so the queue is empty here and this clears
1072
+ // protection rather than seeding it. That is the point: the evictor lives
1073
+ // in a process-wide shared instance that outlives a destroy/configure
1074
+ // cycle, so without this it would keep shielding the previous session's
1075
+ // tracks until the next queue mutation.
1076
+ refreshCacheProtection()
910
1077
  val mediaSourceFactory =
911
1078
  MediaItemBuilder.buildMediaSourceFactory(context, newConfig, lookaheadCache)
912
1079
  val binder = serviceBinder
@@ -943,6 +1110,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
943
1110
  engine.setVolume(volumeState)
944
1111
  engine.setPlaybackSpeed(playbackSpeedState)
945
1112
  engine.setPitchCorrectionMode(pitchCorrectionModeState)
1113
+ installSuppliedReplayGainResolver(engine)
946
1114
  }
947
1115
  installEventObservers()
948
1116
  binder.setCommandHandler(mediaSessionCommandHandler)
@@ -950,7 +1118,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
950
1118
  }
951
1119
 
952
1120
  /**
953
- * Bridge from [PlaybackService.PlaybackCallback] back into this
1121
+ * Bridge from `PlaybackService` its [SessionCommandForwardingPlayer],
1122
+ * [MediaButtonDispatch] and audio-focus listener — back into this
954
1123
  * TrackPlayer. Stamps the `system` pending-reason for transports
955
1124
  * that the system surface drives (lock-screen, Bluetooth, Android
956
1125
  * Auto, hardware media keys), and routes skip-next / skip-previous
@@ -974,12 +1143,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
974
1143
  }
975
1144
 
976
1145
  override fun onSkipToNextCommand(): Boolean {
1146
+ // Cast routing before the local body, same early-return contract as
1147
+ // the public transport methods. The `true` return suppresses the
1148
+ // forwarding player's default seek either way, so the receiver gets
1149
+ // exactly one skip. The reason stamp sits on the local path because
1150
+ // that is the only path whose listener consumes it.
1151
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return true
977
1152
  pendingStateChangeReason = StateChangeReason.SYSTEM
978
1153
  skipToNextInternal()
979
1154
  return true
980
1155
  }
981
1156
 
982
1157
  override fun onSkipToPreviousCommand(): Boolean {
1158
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return true
983
1159
  pendingStateChangeReason = StateChangeReason.SYSTEM
984
1160
  skipToPreviousInternal()
985
1161
  return true
@@ -990,7 +1166,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
990
1166
  // no-ops it (Media3 passes C.INDEX_UNSET when the seek implies
991
1167
  // no move).
992
1168
  if (mediaItemIndex < 0 || mediaItemIndex >= tracks.size) return false
993
- pendingStateChangeReason = StateChangeReason.SYSTEM
994
1169
  // Same path as the public skipToIndex: jump the receiver while
995
1170
  // casting, else the local engine. Both write currentTrackIndex +
996
1171
  // the USER_SKIP_TO_INDEX reason that a raw controller seek skips.
@@ -1000,21 +1175,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1000
1175
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(mediaItemIndex)) {
1001
1176
  return true
1002
1177
  }
1178
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1003
1179
  skipToIndexInternal(mediaItemIndex)
1004
1180
  return true
1005
1181
  }
1006
1182
 
1007
1183
  override fun onMediaPlay() {
1184
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return
1008
1185
  pendingStateChangeReason = StateChangeReason.SYSTEM
1009
1186
  serviceBinder?.engine?.play()
1010
1187
  }
1011
1188
 
1012
1189
  override fun onMediaPause() {
1190
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePause()) return
1013
1191
  pendingStateChangeReason = StateChangeReason.SYSTEM
1014
1192
  serviceBinder?.engine?.pause()
1015
1193
  }
1016
1194
 
1017
1195
  override fun onMediaTogglePlayPause() {
1196
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeTogglePlayPause()) return
1018
1197
  pendingStateChangeReason = StateChangeReason.SYSTEM
1019
1198
  val engine = serviceBinder?.engine ?: return
1020
1199
  // `playWhenReady` (intent to play) is read off the underlying
@@ -1026,11 +1205,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1026
1205
  }
1027
1206
 
1028
1207
  override fun onMediaSkipNext() {
1208
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return
1029
1209
  pendingStateChangeReason = StateChangeReason.SYSTEM
1030
1210
  skipToNextInternal()
1031
1211
  }
1032
1212
 
1033
1213
  override fun onMediaSkipPrevious() {
1214
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return
1034
1215
  pendingStateChangeReason = StateChangeReason.SYSTEM
1035
1216
  skipToPreviousInternal()
1036
1217
  }
@@ -1077,6 +1258,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1077
1258
  * not a correctness gate.
1078
1259
  */
1079
1260
  private fun buildLookaheadStack(config: PlayerConfig, context: Context) {
1261
+ // Ahead of the writer, which captures the monitor's reading at every
1262
+ // reschedule, and a transport flip has to move the window itself. Built
1263
+ // regardless of `enabled` for the same reason the cache is: prefetch can
1264
+ // be turned on at runtime, and the writer built then needs a monitor.
1265
+ cellularTransportMonitor?.stop()
1266
+ cellularTransportMonitor = CellularTransportMonitor(context).apply {
1267
+ // The callback arrives on a ConnectivityManager thread; the reschedule
1268
+ // is main-confined.
1269
+ onTransportChanged = { mainHandler.post { rescheduleLookahead() } }
1270
+ start()
1271
+ }
1080
1272
  // Build the cache regardless of `enabled` so caching can be turned on at
1081
1273
  // runtime (setLookaheadCacheInternal) without a configure() cycle. Media3's
1082
1274
  // SimpleCache fixes its size at construction and allows one instance per
@@ -1106,6 +1298,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1106
1298
  null
1107
1299
  }
1108
1300
  lookaheadCache = cache
1301
+ cache?.servesPlayback = lookaheadConfig.enabled
1109
1302
  // Writer + player factory share state via the cache, not via the
1110
1303
  // http factory instance. Writer is only useful when there's a
1111
1304
  // cache to write into — gate on both `lookaheadConfig.enabled`
@@ -1119,10 +1312,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1119
1312
  networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
1120
1313
  config.networkTimeoutMs
1121
1314
  )
1122
- )
1315
+ ),
1316
+ isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
1123
1317
  ).apply {
1124
1318
  defaultLookaheadCount = lookaheadConfig.lookaheadCount.toInt()
1125
1319
  .coerceAtLeast(0)
1320
+ allowsCellularAccess = lookaheadConfig.allowsCellularAccess ?: true
1126
1321
  onTrackProcessed = { onMainPostStatusEmit() }
1127
1322
  }
1128
1323
  } else {
@@ -1130,20 +1325,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1130
1325
  }
1131
1326
  }
1132
1327
 
1133
- /**
1134
- * Tear down the ExoPlayer + lookahead writer + cache instances so
1135
- * the configure() rebuild path (headers/userAgent change) can
1136
- * recreate them cleanly. The on-disk cache directory is preserved
1137
- * — `SimpleCache.release()` only frees the in-memory instance and
1138
- * its file lock; the entries stay on disk and the next
1139
- * `LookaheadCache(...)` constructor reads them back. Mirrors the
1140
- * SimpleCache "one-instance-per-directory" invariant captured in
1141
- * NOTES.md.
1142
- */
1143
- private fun destroyPlayerKeepCacheOnDisk() {
1144
- releasePlayerAndState(notifyTrackChange = true)
1145
- }
1146
-
1147
1328
  /**
1148
1329
  * Lifecycle destroy body. Releases the ExoPlayer instance and
1149
1330
  * resets every piece of mutable state so a subsequent configure()
@@ -1159,7 +1340,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1159
1340
  // player.
1160
1341
  cancelPendingRebind()
1161
1342
  serviceBinder?.setCommandHandler(null)
1162
- releasePlayerAndState(notifyTrackChange = false)
1343
+ releasePlayerAndState()
1163
1344
  boundContext?.let { ctx ->
1164
1345
  try {
1165
1346
  ctx.unbindService(serviceConnection)
@@ -1188,25 +1369,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1188
1369
  pendingStateChangeReason = null
1189
1370
  pendingTrackChangeReason = null
1190
1371
  lastErrorKey = null
1372
+ cancelPendingRetry()
1191
1373
  retryAttemptsRemaining.clear()
1192
1374
  if (current === this) current = null
1193
1375
  }
1194
1376
 
1195
1377
  /**
1196
- * Shared tear-down for the player + cache + writer + per-position
1197
- * state. Tear order matters: prefetcher releases before the cache
1198
- * so an in-flight `CacheWriter` reading from a released SimpleCache
1199
- * cannot NPE. SimpleCache enforces a one-instance-per-directory
1200
- * invariant for the JVM lifetime, so the in-memory cache object is
1201
- * released here even when the on-disk directory is preserved for
1202
- * a follow-up `configureInternal` to re-open. When
1203
- * `notifyTrackChange` is true, fires a synthetic
1204
- * `(null, -1, QUEUE_REPLACED)` so JS hooks like `useActiveTrack`
1205
- * clear their snapshot — without it a Player UI rendering
1206
- * `#${index+1}/${queueLength}` would show `#1/0` after the queue
1207
- * empties.
1378
+ * Tear-down for the writer, the cache reference and per-position
1379
+ * state, called from [destroyInternal]. The writer's coroutine scope
1380
+ * is cancelled before the cache's MIME store is cleared so a racing
1381
+ * `recordMimeType` cannot land after the clear. The Media3 SimpleCache
1382
+ * is a per-folder process singleton and is not released here
1383
+ * `LookaheadCache.release()` drops only per-instance state; an
1384
+ * eviction-policy change in [configureInternal] releases and rebuilds
1385
+ * it via [discardCacheDirectory].
1208
1386
  */
1209
- private fun releasePlayerAndState(notifyTrackChange: Boolean) {
1387
+ private fun releasePlayerAndState() {
1210
1388
  // Cancel any armed sleep timer up front — the engine (and its volume) is
1211
1389
  // going away, so don't touch it or emit; just stop the tick + reset state.
1212
1390
  stopSleepTimerTick()
@@ -1216,16 +1394,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1216
1394
  tearDownEventObservers()
1217
1395
  lookaheadCacheWriter?.release()
1218
1396
  lookaheadCacheWriter = null
1397
+ // Before dropping the reference: the evictor is process-wide and outlives
1398
+ // this player, so its protected set has to be cleared here or those entries
1399
+ // stay unevictable until the next configure moves the window.
1400
+ // `LookaheadCache.release()` does not touch it.
1401
+ lookaheadCache?.setProtectedUrls(emptySet())
1219
1402
  lookaheadCache?.release()
1220
1403
  lookaheadCache = null
1404
+ cellularTransportMonitor?.stop()
1405
+ cellularTransportMonitor = null
1221
1406
  // Mark unconfigured so the [player] accessor returns null even
1222
1407
  // though the service may still be bound (it unbinds at the
1223
1408
  // bottom of destroyInternal). The PlaybackService releases its
1224
1409
  // own engine (which owns the ExoPlayer instances) on service
1225
1410
  // unbind / onDestroy.
1226
1411
  configured = false
1227
- val hadTracks = tracks.isNotEmpty()
1228
1412
  tracks = emptyList()
1413
+ // The clear emits nothing, so a mirror built from `onQueueChange` deltas
1414
+ // would splice the next delta onto the queue this just dropped. Moving
1415
+ // the revision past the next emit's value is what a subscriber reads as
1416
+ // a missed event, and re-reads.
1417
+ queueRevision += 1
1229
1418
  currentTrackIndex = -1
1230
1419
  currentTrackSource = null
1231
1420
  currentBufferState = BufferState.EMPTY
@@ -1235,9 +1424,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1235
1424
  hasStartedPlaying = false
1236
1425
  wantsToPlay = false
1237
1426
  lastReportedState = PlayerState.NONE
1238
- if (notifyTrackChange && hadTracks) {
1239
- trackChangeListeners.forEach { it(null, -1.0, TrackChangeReason.QUEUE_REPLACED, null) }
1240
- }
1241
1427
  refreshNowPlayingFormatForActiveItem()
1242
1428
  recomputeCapabilities()
1243
1429
  }
@@ -1284,13 +1470,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1284
1470
  if (Looper.myLooper() == Looper.getMainLooper()) {
1285
1471
  return body()
1286
1472
  }
1287
- val latch = CountDownLatch(1)
1473
+ val done = CountDownLatch(1)
1288
1474
  var result: Result<T>? = null
1289
1475
  mainHandler.post {
1290
1476
  result = runCatching { body() }
1291
- latch.countDown()
1477
+ done.countDown()
1292
1478
  }
1293
- latch.await()
1479
+ done.await()
1294
1480
  return result!!.getOrThrow()
1295
1481
  }
1296
1482
 
@@ -1406,6 +1592,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1406
1592
  if (trackIndex < 0 || trackIndex >= engine.allMediaItems.size) return@onMainSync
1407
1593
  currentTrackIndex = trackIndex
1408
1594
  engine.seekTo(trackIndex, maxOf(0L, positionMs))
1595
+ // The index drifted throughout the cast session without touching the
1596
+ // local prefetch or protection windows, so both are stale for the track
1597
+ // local playback is about to resume on.
1598
+ rescheduleLookahead()
1599
+ recomputeCapabilities()
1409
1600
  }
1410
1601
  }
1411
1602
 
@@ -1450,7 +1641,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1450
1641
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1451
1642
  rescheduleLookahead()
1452
1643
  recomputeCapabilities()
1453
- emitQueueChange(QueueChangeReason.SET_QUEUE)
1644
+ emitQueueChange(
1645
+ QueueChangeReason.SET_QUEUE,
1646
+ inserted = tracks.toTypedArray(),
1647
+ insertedAt = if (tracks.isEmpty()) -1 else 0
1648
+ )
1454
1649
  }
1455
1650
 
1456
1651
  /**
@@ -1480,6 +1675,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1480
1675
  // last item must not suppress an identical coded error on the
1481
1676
  // new queue's first item.
1482
1677
  lastErrorKey = null
1678
+ cancelPendingRetry()
1483
1679
  // Reset auto-retry counters: new queue → new budget per index.
1484
1680
  val attempts = effectiveAutoRetries()
1485
1681
  retryAttemptsRemaining =
@@ -1503,19 +1699,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1503
1699
  */
1504
1700
  @MainThread
1505
1701
  internal fun applyResolvedAssistantQueue(
1506
- newTracks: List<TrackItem>
1702
+ newTracks: List<TrackItem>,
1703
+ append: Boolean = false
1507
1704
  ): List<MediaItem> {
1508
- val items = applyTrackListMetadata(newTracks) ?: return emptyList()
1509
- serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1705
+ // `onAddMediaItems` means Media3 will APPEND what this returns, so the
1706
+ // lib's queue has to grow by the same amount. Replacing it here would
1707
+ // leave the player holding old + new while getQueue() reported only the
1708
+ // new tracks, and every index operation after that maps against the
1709
+ // wrong list. `onSetMediaItems` is a genuine replace and keeps that.
1710
+ // Media3 sets the resolved items on the session player after this returns,
1711
+ // which under crossfade addresses the leading leg only. An armed standby
1712
+ // leg still holds the queue as it stood when it was armed, so promoting it
1713
+ // would drop everything added here and auto-advance would stop at the old
1714
+ // queue end. Settle the fade first; the next boundary re-arms from the
1715
+ // updated queue. `CrossfadeEngine.addMediaItems` does this for the paths
1716
+ // that do go through the engine — this is the path that cannot.
1717
+ serviceBinder?.engine?.settlePendingFade()
1718
+ val resolved = if (append) this.tracks + newTracks else newTracks
1719
+ val appendAt = this.tracks.size
1720
+ val previousIndex = this.currentTrackIndex
1721
+ val items = applyTrackListMetadata(resolved) ?: return emptyList()
1722
+ if (append) {
1723
+ // An append does not move the listener off what is playing.
1724
+ this.currentTrackIndex = previousIndex
1725
+ pendingTrackChangeReason = null
1726
+ }
1727
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(resolved))
1510
1728
  rescheduleLookahead()
1511
1729
  recomputeCapabilities()
1512
- emitQueueChange(QueueChangeReason.SET_QUEUE)
1513
- return items
1730
+ // The tracks came from a voice / Android Auto request, so a subscriber has
1731
+ // no other way to learn them — carry them either way.
1732
+ emitQueueChange(
1733
+ if (append) QueueChangeReason.ADD else QueueChangeReason.SET_QUEUE,
1734
+ inserted = if (append) resolved.drop(appendAt).toTypedArray() else resolved.toTypedArray(),
1735
+ insertedAt = if (append) appendAt else if (resolved.isEmpty()) -1 else 0
1736
+ )
1737
+ // Media3 appends only what it is handed back, so return the added slice.
1738
+ return if (append) items.drop(appendAt) else items
1514
1739
  }
1515
1740
 
1516
1741
  override fun addToQueue(tracks: Array<TrackItem>, insertBefore: Double?): Promise<Unit> =
1517
1742
  Promise.async {
1518
- // Mirror `seekToInternal:890` non-finite guard: NaN/±Infinity
1743
+ // Mirror `seekToInternal`'s non-finite guard: NaN/±Infinity
1519
1744
  // from JS would `.toInt()` to MIN/MAX or 0 silently. When
1520
1745
  // insertBefore is non-finite, drop it (treat as "append at
1521
1746
  // end") rather than silently jumping to position 0.
@@ -1566,10 +1791,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1566
1791
  )
1567
1792
 
1568
1793
  // Index-keyed retry-budget map: insertion at `at` shifts every
1569
- // existing key >= at up by `newTracks.size`. Snapshot via
1570
- // `filterKeys` so mutation of the live map is safe regardless of
1571
- // iteration order; `toSortedMap` is incidental cleanliness, not
1572
- // load-bearing for correctness.
1794
+ // existing key >= at up by `newTracks.size`. The shift must run in
1795
+ // descending key order: each iteration removes `oldIdx` and writes
1796
+ // `oldIdx + shift`, and in ascending order the write for key k lands
1797
+ // on k + shift, and the later iteration for k + shift removes it, so
1798
+ // that budget is lost.
1799
+ cancelPendingRetry()
1573
1800
  val attempts = effectiveAutoRetries()
1574
1801
  val shift = newTracks.size
1575
1802
  val shifted = retryAttemptsRemaining
@@ -1602,7 +1829,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1602
1829
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1603
1830
  rescheduleLookahead()
1604
1831
  recomputeCapabilities()
1605
- emitQueueChange(QueueChangeReason.ADD)
1832
+ emitQueueChange(
1833
+ QueueChangeReason.ADD,
1834
+ inserted = newTracks.toTypedArray(),
1835
+ insertedAt = at
1836
+ )
1606
1837
  return at
1607
1838
  }
1608
1839
 
@@ -1686,6 +1917,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1686
1917
  // - **Why not preserve via remap**: requires walking the map
1687
1918
  // AND the sanitised-indices in lockstep with offset
1688
1919
  // accounting. The clear-and-repopulate is intentional.
1920
+ cancelPendingRetry()
1689
1921
  val attempts = effectiveAutoRetries()
1690
1922
  retryAttemptsRemaining.clear()
1691
1923
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
@@ -1698,7 +1930,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1698
1930
  recomputeCapabilities()
1699
1931
  // Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
1700
1932
  // removed; tracks + index are final.
1701
- emitQueueChange(QueueChangeReason.REMOVE)
1933
+ emitQueueChange(QueueChangeReason.REMOVE, removed = sanitised)
1702
1934
  return sanitised
1703
1935
  }
1704
1936
 
@@ -1709,7 +1941,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1709
1941
  // mirror the reorder to the receiver. `routeMoveInQueue` is
1710
1942
  // suspending so it runs outside `onMain`. Mirrors iOS `moveInQueue`.
1711
1943
  val move = onMain {
1712
- // Mirror `seekToInternal:890` non-finite guard.
1944
+ // Mirror `seekToInternal`'s non-finite guard.
1713
1945
  if (!fromIndex.isFinite() || !toIndex.isFinite()) return@onMain null
1714
1946
  val from = fromIndex.toInt()
1715
1947
  val to = toIndex.toInt()
@@ -1759,6 +1991,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1759
1991
  // arbitrarily. Cheapest correct path is to clear + repopulate
1760
1992
  // (same trade-off as removeFromQueueInternal — structural reset
1761
1993
  // semantics for queue mutations).
1994
+ cancelPendingRetry()
1762
1995
  val attempts = effectiveAutoRetries()
1763
1996
  retryAttemptsRemaining.clear()
1764
1997
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
@@ -1766,12 +1999,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1766
1999
  recomputeCapabilities()
1767
2000
  // Reached only past the invalid / `from == to` / pinned-current
1768
2001
  // guards, so the order actually changed.
1769
- emitQueueChange(QueueChangeReason.MOVE)
2002
+ emitQueueChange(QueueChangeReason.MOVE, movedFrom = from, movedTo = to)
1770
2003
  return true
1771
2004
  }
1772
2005
 
1773
- override fun getQueue(): Array<TrackItem> =
1774
- tracks.toTypedArray()
2006
+ override fun getQueue(): Promise<Array<TrackItem>> =
2007
+ Promise.async { onMain { tracks.toTypedArray() } }
1775
2008
 
1776
2009
  override fun clearQueue(): Promise<Unit> =
1777
2010
  Promise.async {
@@ -1800,6 +2033,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1800
2033
  // Reset error-dedup so a subsequent queue's identical-coded error
1801
2034
  // isn't suppressed by a stale entry from the cleared queue.
1802
2035
  lastErrorKey = null
2036
+ cancelPendingRetry()
1803
2037
  retryAttemptsRemaining.clear()
1804
2038
  tracks = emptyList()
1805
2039
  currentTrackIndex = -1
@@ -1854,18 +2088,23 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1854
2088
  // engine. Same shape at every transport entry point — see
1855
2089
  // `CastTransportRouter` for the early-return contract.
1856
2090
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1857
- // Stuck-recovery: if the user re-taps play after
1858
- // an exhausted-retries error, give one more shot. Reset
1859
- // counter to 1 (single shot, not full autoRetries — each
1860
- // user tap = one attempt for predictable UX) and re-prepare.
1861
- // Mirrors iOS `play()` stuck-recovery.
2091
+ // Stuck-recovery: if the user re-taps play after an exhausted-retries
2092
+ // error, hand the track back the allowance the consumer configured and
2093
+ // re-prepare. Not a hard-coded 1: a consumer who set `autoRetries: 0`
2094
+ // asked for none. Mirrors iOS `play()` stuck-recovery.
1862
2095
  if (lastReportedState == PlayerState.ERROR &&
1863
2096
  currentTrackIndex >= 0 &&
1864
2097
  (retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
1865
- retryAttemptsRemaining[currentTrackIndex] = 1
2098
+ retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
1866
2099
  lastErrorKey = null
1867
2100
  player?.prepare()
1868
2101
  }
2102
+ // At the end of the queue there is nothing to play: ExoPlayer stays
2103
+ // STATE_ENDED through play(), and a playWhenReady left set would
2104
+ // start the next track a skip seats without anyone asking. A skip, a
2105
+ // seek or a fresh queue leaves this state; play() alone restarts
2106
+ // nothing — the same contract as iOS.
2107
+ if (player?.playbackState == Player.STATE_ENDED && tracks.isNotEmpty()) return@onMain
1869
2108
  // Stash USER on the pending-reason channel so the
1870
2109
  // subsequent Player.Listener fire attributes the transition
1871
2110
  // correctly.
@@ -1882,14 +2121,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1882
2121
  // During cast the receiver owns playback — forward a play so a failed
1883
2122
  // receiver item re-attempts.
1884
2123
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1885
- // Local recovery: only meaningful for a track that errored after its
1886
- // automatic retries were exhausted. Give it one fresh attempt reset
1887
- // the per-track retry budget, then rebuild the failed item via
1888
- // retryFailedItem, which discards the wedged DataSource (a bare
1889
- // prepare() would re-use it), restores the pre-failure position, and
1890
- // resumes playing.
1891
- if (lastReportedState != PlayerState.ERROR || currentTrackIndex < 0) return@onMain
1892
- retryAttemptsRemaining[currentTrackIndex] = 1
2124
+ // A user asking for a retry has a reason, and the library is not the
2125
+ // one to judge it. This is the manual attempt once the automatic one
2126
+ // is spent, so gating it on the player's state would gate recovery on
2127
+ // the state of a player that has already failed to recover — and a
2128
+ // wedged decoder still reporting PLAYING is exactly when someone
2129
+ // reaches for the button. retryFailedItem below discards the wedged
2130
+ // DataSource (a bare prepare() would re-use it), restores the
2131
+ // pre-failure position, and resumes playing.
2132
+ //
2133
+ // Only the bounds remain: no current track is genuinely nothing to
2134
+ // retry, which is not second-guessing the request.
2135
+ if (currentTrackIndex < 0) return@onMain
2136
+ // The configured budget, not a hard-coded 1: this restores the track's
2137
+ // automatic allowance for whatever fails *next*, and a consumer who
2138
+ // set `autoRetries: 0` asked for none.
2139
+ retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
1893
2140
  lastErrorKey = null
1894
2141
  pendingStateChangeReason = StateChangeReason.USER
1895
2142
  wantsToPlay = true
@@ -2096,7 +2343,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2096
2343
  override fun skipToIndex(index: Double): Promise<Unit> =
2097
2344
  Promise.async {
2098
2345
  onMain {
2099
- // Mirror `seekToInternal:890` non-finite guard. Without this
2346
+ // Mirror `seekToInternal`'s non-finite guard. Without this
2100
2347
  // `Double.NaN.toInt()` returns 0 — `skipToIndex(NaN)` would
2101
2348
  // silently jump to track 0 instead of no-op'ing. iOS uses
2102
2349
  // `InputGuards.validQueueIndex` for the same guarantee.
@@ -2228,14 +2475,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2228
2475
  repeatMode = repeatModeAsSkipMode()
2229
2476
  )
2230
2477
 
2231
- @VisibleForTesting
2232
- internal fun computePreviousIndex(): Int? =
2233
- QueueSkipArithmetic.computePrevious(
2234
- trackCount = tracks.size,
2235
- currentIndex = currentTrackIndex,
2236
- repeatMode = repeatModeAsSkipMode()
2237
- )
2238
-
2239
2478
  private fun repeatModeAsSkipMode(): QueueSkipRepeatMode = when (repeatModeState) {
2240
2479
  RepeatMode.OFF -> QueueSkipRepeatMode.OFF
2241
2480
  RepeatMode.TRACK -> QueueSkipRepeatMode.TRACK
@@ -2267,7 +2506,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2267
2506
  * clearQueueInternal, skipToIndexInternal / skipToNextInternal /
2268
2507
  * skipToPreviousInternal (all index writes), setRepeatModeInternal,
2269
2508
  * shuffleQueueInternal, handleMediaItemTransition (covers Media3
2270
- * REASON_AUTO + REASON_REPEAT auto-advance), destroyInternal.
2509
+ * REASON_AUTO + REASON_REPEAT auto-advance), the crossfade
2510
+ * fade-start / cancel index writes, emitRemoteTrackChange and
2511
+ * resumeLocalAfterCast (cast), destroyInternal.
2271
2512
  */
2272
2513
  @MainThread
2273
2514
  internal fun recomputeCapabilities() {
@@ -2458,6 +2699,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2458
2699
  newEngine.setVolume(volumeState)
2459
2700
  newEngine.setPlaybackSpeed(playbackSpeedState)
2460
2701
  newEngine.setPitchCorrectionMode(pitchCorrectionModeState)
2702
+ installSuppliedReplayGainResolver(newEngine)
2461
2703
  // Drive the AirPlay receiver-display metadata sync. When the new engine is
2462
2704
  // the dedicated AirPlay engine, hand the now-active session this engine's
2463
2705
  // ExoPlayer — registered in AirPlayEngine.init, so CastSinkRouter.player()
@@ -2477,7 +2719,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2477
2719
  override fun setPlaybackSpeed(rate: Double): Promise<Unit> =
2478
2720
  Promise.async {
2479
2721
  onMain {
2480
- // Mirror `seekToInternal:890` non-finite guard. NaN →
2722
+ // Mirror `seekToInternal`'s non-finite guard. NaN →
2481
2723
  // undefined ExoPlayer audio behaviour at the AudioSink;
2482
2724
  // ±Infinity → IllegalArgumentException from
2483
2725
  // `PlaybackParameters` constructor.
@@ -2525,7 +2767,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2525
2767
  override fun setVolume(volume: Double): Promise<Unit> =
2526
2768
  Promise.async {
2527
2769
  onMain {
2528
- // Mirror `seekToInternal:890` non-finite guard. NaN →
2770
+ // Mirror `seekToInternal`'s non-finite guard. NaN →
2529
2771
  // undefined audio output; ±Infinity → ditto.
2530
2772
  if (!volume.isFinite()) return@onMain
2531
2773
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetVolume(volume.toFloat())) return@onMain
@@ -2828,13 +3070,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2828
3070
  // resume after the rebuild.
2829
3071
  val wasPlaying = p.isPlaying
2830
3072
 
2831
- // Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
2832
- val newTracks = tracks.shuffled()
3073
+ // Shuffle positions rather than tracks: the permutation is what
3074
+ // onQueueChange reports, and it cannot be recovered from the reordered
3075
+ // list afterwards. Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
3076
+ val order = tracks.indices.shuffled()
3077
+ val newTracks = order.map { tracks[it] }
2833
3078
 
2834
3079
  this.tracks = newTracks
2835
3080
  this.currentTrackIndex = 0
2836
3081
  pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
2837
3082
  // Destructive shuffle replaces every position; reset retry budget.
3083
+ cancelPendingRetry()
2838
3084
  val attempts = effectiveAutoRetries()
2839
3085
  retryAttemptsRemaining.clear()
2840
3086
  for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
@@ -2852,7 +3098,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2852
3098
  rescheduleLookahead()
2853
3099
  recomputeCapabilities()
2854
3100
  // Skip a single-track shuffle — the order can't change.
2855
- if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
3101
+ if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE, order = order)
2856
3102
 
2857
3103
  return ShuffleResult(
2858
3104
  tracks = newTracks.toTypedArray(),
@@ -2875,13 +3121,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2875
3121
  // goes away.
2876
3122
 
2877
3123
  /**
2878
- * Snapshot of the ExoPlayer's current state mapped onto the
2879
- * public [PlayerState] enum. Safe to call from any thread
2880
- * hops to main via [syncMain] so Media3's thread-assertion does
2881
- * not fire.
3124
+ * Snapshot of the active playback source's state mapped onto the public
3125
+ * [PlayerState] enum the cast receiver while a session owns playback,
3126
+ * otherwise the local ExoPlayer. Safe to call from any thread: the receiver
3127
+ * read is lock-free, and the local read hops to main via [syncMain] so
3128
+ * Media3's thread-assertion does not fire.
2882
3129
  */
2883
- override fun getState(): PlayerState =
2884
- syncMain { getStateInternal() }
3130
+ override fun getState(): PlayerState {
3131
+ // The receiver owns playback during a cast session — the local player is
3132
+ // parked and paused for its duration, so its derived state describes
3133
+ // nothing the user can hear. Until the receiver has reported (session
3134
+ // still activating, holding its initial IDLE) the local read is the
3135
+ // better answer; after that the receiver is authoritative, including a
3136
+ // later IDLE, which means it genuinely has nothing loaded.
3137
+ val remote = com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote()
3138
+ if (remote != null && remote.hasReportedState) {
3139
+ return com.margelo.nitro.queueplayer.cast.mapRemotePlaybackState(remote.state.value)
3140
+ }
3141
+ return syncMain { getStateInternal() }
3142
+ }
2885
3143
 
2886
3144
  @VisibleForTesting
2887
3145
  internal fun getStateInternal(): PlayerState {
@@ -2896,11 +3154,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2896
3154
  )
2897
3155
  }
2898
3156
 
2899
- // No `syncMain` hop on the next 3 getters: each reads a single
3157
+ // No `syncMain` hop on the next five getters: each reads a single
2900
3158
  // `@Volatile` field that is only written on main, so the JVM
2901
3159
  // memory model gives the cross-thread happens-before edge for
2902
- // free. iOS uses `onMainSync` because Swift has no `@Volatile`-
2903
- // equivalent storage class.
3160
+ // free. iOS hops through `onPlayerQueueSync` instead: its state is
3161
+ // confined to `playerQueue`, and Swift has no `@Volatile`-equivalent
3162
+ // storage class.
2904
3163
  override fun getCurrentTrackIndex(): Double = currentTrackIndex.toDouble()
2905
3164
 
2906
3165
  override fun getCurrentTrackSource(): TrackSource? = currentTrackSource
@@ -2944,26 +3203,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2944
3203
  }
2945
3204
 
2946
3205
  override fun onQueueChange(
2947
- callback: (queue: Array<TrackItem>, currentIndex: Double, reason: QueueChangeReason) -> Unit
3206
+ callback: (delta: QueueChangeDelta, currentIndex: Double, reason: QueueChangeReason) -> Unit
2948
3207
  ): () -> Unit {
2949
3208
  val id = queueChangeListeners.add(callback)
2950
3209
  return { queueChangeListeners.remove(id) }
2951
3210
  }
2952
3211
 
2953
3212
  /**
2954
- * Fire onQueueChange with an atomic snapshot of the post-mutation
2955
- * queue + current index. Main-thread only every queue mutation
2956
- * commits on main, so [tracks] / [currentTrackIndex] are settled here.
2957
- * Callers gate this on an actual content/order change so a no-op
2958
- * mutation never emits.
3213
+ * Emit a queue change as a description of what happened rather than a copy of
3214
+ * the queue. Callers pass only the fields their mutation defines; the rest
3215
+ * stay empty, and `revision` lets a subscriber notice a dropped event.
3216
+ * Main-thread only every queue mutation commits on main, so [tracks] /
3217
+ * [currentTrackIndex] are settled here. Callers gate this on an actual
3218
+ * content/order change so a no-op mutation never emits.
2959
3219
  */
2960
3220
  @MainThread
2961
- private fun emitQueueChange(reason: QueueChangeReason) {
2962
- val snapshot = tracks.toTypedArray()
3221
+ private fun emitQueueChange(
3222
+ reason: QueueChangeReason,
3223
+ inserted: Array<TrackItem> = emptyArray(),
3224
+ insertedAt: Int = -1,
3225
+ removed: List<Int> = emptyList(),
3226
+ movedFrom: Int = -1,
3227
+ movedTo: Int = -1,
3228
+ order: List<Int> = emptyList()
3229
+ ) {
3230
+ queueRevision += 1
3231
+ val delta = QueueChangeDelta(
3232
+ revision = queueRevision.toDouble(),
3233
+ length = tracks.size.toDouble(),
3234
+ inserted = inserted,
3235
+ insertedAt = insertedAt.toDouble(),
3236
+ removed = removed.map { it.toDouble() }.toDoubleArray(),
3237
+ movedFrom = movedFrom.toDouble(),
3238
+ movedTo = movedTo.toDouble(),
3239
+ order = order.map { it.toDouble() }.toDoubleArray()
3240
+ )
2963
3241
  val index = currentTrackIndex.toDouble()
2964
- queueChangeListeners.forEach { it(snapshot, index, reason) }
3242
+ queueChangeListeners.forEach { it(delta, index, reason) }
2965
3243
  }
2966
3244
 
3245
+ /** Increments once per queue-change emit; a gap tells a subscriber it missed one. */
3246
+ private var queueRevision: Long = 0
3247
+
2967
3248
  override fun onProgress(
2968
3249
  callback: (progress: PlayerProgress) -> Unit
2969
3250
  ): () -> Unit {
@@ -3125,15 +3406,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3125
3406
  * drained). Post-bind: registers immediately. Either way the
3126
3407
  * returned disposer matches the consumer's expectation that
3127
3408
  * calling it tears the registration down regardless of timing.
3128
- *
3129
- * Past failure: `bootstrap.ts` (demo) calls `registerPlaybackService`
3130
- * at bundle-load time, which fires every `onCarConnect` /
3131
- * `onPlayFromIdRequest` / etc. BEFORE `configure()` has bound the
3132
- * service. The old "?: NO_OP_DISPOSER" fallback silently dropped
3133
- * every registration; the carService thought it had wired up but
3134
- * the lib had nothing recorded, so Android Auto's later bind hit
3135
- * `dispatchCarConnect` with a null callback + timed out → AA
3136
- * "No items".
3409
+ * `registerPlaybackService` may run before `configure()` resolves, and
3410
+ * [serviceBinder] is null again between an OS service kill and the
3411
+ * rebind (`onServiceDisconnected`); both cases queue here and are
3412
+ * applied by [drainPendingBrowseRegistrations] on the next
3413
+ * `onServiceConnected`.
3137
3414
  */
3138
3415
  private fun deferredRegister(
3139
3416
  register: (BrowseCallbackRegistry) -> () -> Unit
@@ -3179,14 +3456,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3179
3456
  }
3180
3457
  }
3181
3458
 
3182
- override fun getLookaheadCacheStatus(): CacheStatus =
3183
- syncMain { buildCacheStatusSnapshot() }
3459
+ /**
3460
+ * Asynchronous because the snapshot counts fully-cached entries, which walks
3461
+ * every cache key and takes the cache's monitor per key — contended against
3462
+ * the prefetch thread. Resolved off both the JS thread and the app looper;
3463
+ * every field it reads is published for that.
3464
+ */
3465
+ override fun getLookaheadCacheStatus(): Promise<CacheStatus> =
3466
+ Promise.async { buildCacheStatusSnapshot() }
3184
3467
 
3468
+ /**
3469
+ * Wipe the cache and resolve once it is actually gone.
3470
+ *
3471
+ * The delete is file IO, so it runs off the app looper; the promise resolves
3472
+ * after it, because a consumer that awaits this and then reads
3473
+ * `getLookaheadCacheStatus()` must not see the pre-wipe size.
3474
+ */
3185
3475
  override fun clearLookaheadCache(): Promise<Unit> =
3186
- Promise.async { onMain { clearLookaheadCacheInternal() } }
3476
+ Promise.async {
3477
+ onMain { clearLookaheadCacheInternal() }?.join()
3478
+ onMain { emitCacheStatus() }
3479
+ }
3187
3480
 
3188
3481
  override fun onCacheStatusChange(callback: (status: CacheStatus) -> Unit): () -> Unit {
3189
3482
  val id = cacheStatusListeners.add(callback)
3483
+ // Deliver the current status once. Otherwise a settings screen shows its
3484
+ // placeholder until the next prefetch completes — which never happens for a
3485
+ // fully-cached queue or a consumer running with prefetch off.
3486
+ cacheIoScope.launch(cacheStatusDispatcher) {
3487
+ val snapshot = buildCacheStatusSnapshot()
3488
+ mainHandler.post {
3489
+ if (cacheStatusListeners.contains(id)) callback(snapshot)
3490
+ }
3491
+ }
3190
3492
  return { cacheStatusListeners.remove(id) }
3191
3493
  }
3192
3494
 
@@ -3243,6 +3545,30 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3243
3545
 
3244
3546
  override fun getReplayGainMode(): ReplayGainMode = ReplayGainConfig.getMode()
3245
3547
 
3548
+ /**
3549
+ * Hand a freshly-installed engine the reader for consumer-supplied
3550
+ * ReplayGain. The engine holds no queue metadata of its own — it
3551
+ * plays `MediaItem`s — so the lookup from a queue index back to the
3552
+ * consumer's `TrackItem` lives here, where the canonical [tracks]
3553
+ * list does.
3554
+ *
3555
+ * The lambda reads [tracks] on every invocation rather than closing
3556
+ * over a snapshot, so a `setQueue` carrying corrected values is
3557
+ * picked up by the next recompute. Called wherever an engine is
3558
+ * installed — the configure-time rebuild and the playback-mode swap —
3559
+ * because a fresh engine starts with no resolver.
3560
+ */
3561
+ private fun installSuppliedReplayGainResolver(engine: PlaybackEngine?) {
3562
+ val resolver: (Int) -> SuppliedReplayGain? = { index ->
3563
+ tracks.getOrNull(index)?.let { SuppliedReplayGain.from(it) }
3564
+ }
3565
+ when (engine) {
3566
+ is GaplessEngine -> engine.suppliedReplayGainResolver = resolver
3567
+ is CrossfadeEngine -> engine.suppliedReplayGainResolver = resolver
3568
+ else -> Unit
3569
+ }
3570
+ }
3571
+
3246
3572
  /**
3247
3573
  * Re-resolve the active item's format and emit if it differs from
3248
3574
  * the last cached value. Call sites: [Player.Listener.onMediaItemTransition]
@@ -3302,14 +3628,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3302
3628
  * live — no configure() cycle needed:
3303
3629
  *
3304
3630
  * - `enabled` toggle:
3305
- * - true → false: tears down the WRITER (no more proactive
3306
- * prefetch). The CACHE stays alive Media3's CacheDataSource
3307
- * in the player's MediaSource.Factory still reads existing
3308
- * entries, so disabling can't yank cache out from under
3309
- * in-flight reads.
3631
+ * - true → false: tears down the WRITER (no more proactive prefetch)
3632
+ * and routes the player's reads to the origin from the next item
3633
+ * load; the item already loading keeps the source it opened. The
3634
+ * cache OBJECT stays alive (Media3 allows one per directory) but is
3635
+ * neither read nor written while disabled.
3310
3636
  * - false → true: rebuilds the writer against the existing cache
3311
- * (the cache is built at configure() regardless of `enabled`),
3312
- * so caching turns on without a configure() cycle.
3637
+ * (the cache is built at configure() regardless of `enabled`) and
3638
+ * routes reads through it again, so caching turns on without a
3639
+ * configure() cycle.
3313
3640
  *
3314
3641
  * - `lookaheadCount` change: live update via
3315
3642
  * `LookaheadCacheWriter.defaultLookaheadCount`. Applies on the
@@ -3331,8 +3658,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3331
3658
 
3332
3659
  val countChanged = newConfig.lookaheadCount != previous.lookaheadCount
3333
3660
  val enableChanged = newConfig.enabled != previous.enabled
3334
-
3335
- // Disable tear down WRITER only. Cache stays for player reads.
3661
+ val cellularChanged =
3662
+ (newConfig.allowsCellularAccess ?: true) != (previous.allowsCellularAccess ?: true)
3663
+
3664
+ // The player's reads follow the flag from the next item load: disabled
3665
+ // means the origin, whatever the disk holds. The cache object stays —
3666
+ // Media3 allows one instance per directory — but nothing reads or writes
3667
+ // it until it is enabled again.
3668
+ if (enableChanged) {
3669
+ lookaheadCache?.servesPlayback = newConfig.enabled
3670
+ }
3671
+ // Disable → tear down the writer.
3336
3672
  if (enableChanged && !newConfig.enabled) {
3337
3673
  lookaheadCacheWriter?.release()
3338
3674
  lookaheadCacheWriter = null
@@ -3353,51 +3689,80 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3353
3689
  networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
3354
3690
  config.networkTimeoutMs
3355
3691
  )
3356
- )
3692
+ ),
3693
+ isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
3357
3694
  ).apply {
3358
3695
  defaultLookaheadCount =
3359
3696
  newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3697
+ allowsCellularAccess = newConfig.allowsCellularAccess ?: true
3360
3698
  onTrackProcessed = { onMainPostStatusEmit() }
3361
3699
  }
3362
3700
  rescheduleLookahead()
3363
3701
  }
3364
3702
  }
3365
3703
 
3366
- // Live count update — safe regardless of player lifecycle. Skipped when
3367
- // the enable branch above just rebuilt the writer (it already applied the
3368
- // new count + rescheduled), so a single enable+count change reschedules once.
3704
+ // Live policy update — safe regardless of player lifecycle. `lookaheadCount`
3705
+ // resizes the window; `allowsCellularAccess` decides whether the window is
3706
+ // fetched at all, so turning it off empties it and cancels the prefetch in
3707
+ // flight and turning it back on refills it. Both take effect on the single
3708
+ // reschedule below. Skipped when the enable branch above just rebuilt the
3709
+ // writer, which already applied both values and rescheduled.
3369
3710
  val writer = lookaheadCacheWriter
3370
- if (countChanged && !enableChanged && writer != null) {
3371
- writer.defaultLookaheadCount =
3372
- newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3711
+ if (!enableChanged && writer != null && (countChanged || cellularChanged)) {
3712
+ if (countChanged) {
3713
+ writer.defaultLookaheadCount =
3714
+ newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3715
+ }
3716
+ if (cellularChanged) {
3717
+ writer.allowsCellularAccess = newConfig.allowsCellularAccess ?: true
3718
+ }
3373
3719
  rescheduleLookahead()
3374
3720
  }
3375
3721
 
3722
+ // `lookaheadCount` sizes the protected window, so a change to it must move
3723
+ // the window even when the branches above did not reschedule — prefetch
3724
+ // disabled leaves no writer, and a combined disable+count change skips the
3725
+ // count branch entirely. The cache still evicts in both cases.
3726
+ refreshCacheProtection()
3376
3727
  emitCacheStatus()
3377
3728
  }
3378
3729
 
3730
+ /**
3731
+ * Cancel the prefetch and wipe the cache, returning the job doing the file
3732
+ * work so a caller can await it. Null when there is no cache to clear.
3733
+ *
3734
+ * The wipe is a per-resource delete walk, so it runs off the app looper.
3735
+ * Cancelling the prefetch first is what keeps it from racing: a `CacheWriter`
3736
+ * mid-write into a key just removed re-creates the entry seconds later.
3737
+ *
3738
+ * Deliberately does NOT call [rescheduleLookahead] — an immediate refill makes
3739
+ * `clearLookaheadCache` impossible to observe at zero from JS, and "clear"
3740
+ * means "wipe and stop" (the consumer re-triggers prefetch via
3741
+ * setLookaheadCache, skipToIndex, or setQueue). Matches iOS.
3742
+ */
3743
+ @MainThread
3379
3744
  @VisibleForTesting
3380
- internal fun clearLookaheadCacheInternal() {
3381
- val cache = lookaheadCache ?: return
3382
- // Cancel any in-flight prefetch first so we don't race with the
3383
- // wipe (CacheWriter mid-write into a key we just removed would
3384
- // re-create the entry seconds later). We deliberately do NOT
3385
- // call rescheduleLookahead() here — an immediate refill makes
3386
- // `clearLookaheadCache` impossible to observe at zero from JS,
3387
- // and "clear" semantically means "wipe and stop" (consumer can
3388
- // re-trigger prefetch via setLookaheadCache, skipToIndex, or
3389
- // setQueue). Matches iOS.
3745
+ internal fun clearLookaheadCacheInternal(): Job? {
3746
+ val cache = lookaheadCache ?: return null
3747
+ // Stops the blocking CacheWriter too, not just the coroutine. Its cancelled
3748
+ // flag is polled between chunk reads, so a span already mid-write can still
3749
+ // land after the wipe; this closes the window rather than eliminating it.
3390
3750
  lookaheadCacheWriter?.cancel()
3391
- cache.clear()
3392
- emitCacheStatus()
3751
+ // File work only. Emitting from in here would make the job depend on the
3752
+ // main thread, which deadlocks anyone awaiting it with a blocking wait on
3753
+ // that thread. The caller emits instead.
3754
+ return cacheIoScope.launch { cache.clear() }
3393
3755
  }
3394
3756
 
3395
3757
  /**
3396
3758
  * Build a [CacheStatus] snapshot from the current cache + writer
3397
- * state. Synchronouscalled inside `syncMain` from
3398
- * [getLookaheadCacheStatus].
3759
+ * state. Called off the app looper from the `Promise.async` pool
3760
+ * (`getLookaheadCacheStatus`) and the serialised cache-status IO
3761
+ * dispatcher (`emitCacheStatus` / `onCacheStatusChange`); reads
3762
+ * `lookaheadCache` / `lookaheadCacheWriter` as @Volatile snapshots.
3399
3763
  */
3400
- private fun buildCacheStatusSnapshot(): CacheStatus {
3764
+ @VisibleForTesting
3765
+ internal fun buildCacheStatusSnapshot(): CacheStatus {
3401
3766
  val cache = lookaheadCache
3402
3767
  val writer = lookaheadCacheWriter
3403
3768
  val currentSizeBytes = cache?.currentSizeBytes() ?: 0L
@@ -3420,21 +3785,32 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3420
3785
  )
3421
3786
  }
3422
3787
 
3423
- /** Fire the cache-status listeners with a fresh snapshot. Called on main. */
3788
+ /**
3789
+ * Fire the cache-status listeners with a fresh snapshot. Called from main
3790
+ * and from the writer's IO thread (`onTrackProcessed`); the registry is
3791
+ * copy-on-write and the listeners run on main either way.
3792
+ */
3424
3793
  private fun emitCacheStatus() {
3425
3794
  if (cacheStatusListeners.isEmpty) return
3426
- val snapshot = buildCacheStatusSnapshot()
3427
- cacheStatusListeners.forEach { it(snapshot) }
3795
+ // Built off the app looper. The snapshot counts fully-cached entries,
3796
+ // which walks every cache key and takes the cache's monitor per key —
3797
+ // contended against the prefetch thread that is committing downloads —
3798
+ // and this fires after every prefetch iteration whenever a consumer is
3799
+ // subscribed. Listeners are still called on the looper they expect.
3800
+ cacheIoScope.launch(cacheStatusDispatcher) {
3801
+ val snapshot = buildCacheStatusSnapshot()
3802
+ mainHandler.post { cacheStatusListeners.forEach { it(snapshot) } }
3803
+ }
3428
3804
  }
3429
3805
 
3430
3806
  /**
3431
- * Hop to main + emit a cache-status snapshot. Bound to the writer's
3432
- * `onTrackProcessed` hook so post-download status updates are
3433
- * always dispatched on main even though the writer's coroutine
3434
- * runs on Dispatchers.IO.
3807
+ * Emit a cache-status snapshot. Bound to the writer's `onTrackProcessed`
3808
+ * hook, so it fires once per prefetched track.
3435
3809
  */
3436
3810
  private fun onMainPostStatusEmit() {
3437
- mainHandler.post { emitCacheStatus() }
3811
+ // `emitCacheStatus` does its own hopping — off the looper to build, back
3812
+ // onto it to deliver — so there is nothing to post first.
3813
+ emitCacheStatus()
3438
3814
  }
3439
3815
 
3440
3816
  // --- Events — observer wiring + dispatch
@@ -3465,12 +3841,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3465
3841
  }
3466
3842
 
3467
3843
  private fun tearDownEventObservers() {
3844
+ // A retry posted before teardown would otherwise land on a released engine.
3845
+ // Cancelled here rather than in stopProgressTick, which also runs when the
3846
+ // last progress subscriber unsubscribes — a screen unmounting must not kill
3847
+ // an in-flight retry.
3848
+ cancelPendingRetry()
3468
3849
  // Clear the delegate from the engine TrackPlayer last wired
3469
3850
  // itself onto, even if [serviceBinder] has been re-bound to a
3470
3851
  // fresh engine since.
3471
3852
  engineDelegateRegistration?.let { engine ->
3472
3853
  if (engine.delegate === this) engine.delegate = null
3473
3854
  }
3855
+ // And from whatever engine is bound now. A playback-mode swap carries the
3856
+ // delegate across to the incoming engine without this registration
3857
+ // following it, so clearing only the remembered one unwires an engine
3858
+ // that has already been released and leaves the live one still
3859
+ // delivering into a torn-down player.
3860
+ serviceBinder?.engine?.let { engine ->
3861
+ if (engine.delegate === this) engine.delegate = null
3862
+ }
3474
3863
  engineDelegateRegistration = null
3475
3864
  stopProgressTick()
3476
3865
  }
@@ -3544,8 +3933,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3544
3933
  p.replaceMediaItem(idx, updated)
3545
3934
  }
3546
3935
 
3547
- override fun onError(engine: PlaybackEngine, exception: PlaybackException) {
3548
- handlePlayerError(exception)
3936
+ override fun onError(
3937
+ engine: PlaybackEngine, exception: PlaybackException, failedIndex: Int
3938
+ ) {
3939
+ handlePlayerError(exception, failedIndex)
3549
3940
  }
3550
3941
 
3551
3942
  override fun onPlaybackEnded(engine: PlaybackEngine) {
@@ -3571,12 +3962,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3571
3962
  currentTrackIndex = incomingIndex
3572
3963
  lastErrorKey = null
3573
3964
  val track = tracks[incomingIndex]
3574
- val cache = lookaheadCache
3575
- currentTrackSource = classifyTrackSource(track.url) { url ->
3576
- cache?.isFullyCached(url) == true
3577
- }
3965
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3578
3966
  crossfadeFadeStartEmittedIdx = incomingIndex
3579
3967
  pendingTrackChangeReason = null
3968
+ logPlaybackSource(track.url)
3580
3969
  // New active track => new milestone playthrough (the matching
3581
3970
  // structural transition echo is deduped in handleMediaItemTransition).
3582
3971
  milestoneTracker.reset()
@@ -3588,6 +3977,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3588
3977
  rescheduleLookahead()
3589
3978
  }
3590
3979
 
3980
+ /**
3981
+ * Records what the player is actually reading for the active track.
3982
+ *
3983
+ * The reported source says whether the file is on disk; it does not say the
3984
+ * bytes reaching the decoder came from there. Media3 reads through
3985
+ * [LookaheadCache]'s CacheDataSource, so a rising cached-bytes total is the
3986
+ * proof that playback is being served from the cache rather than the origin.
3987
+ */
3988
+ private fun logPlaybackSource(url: String?) {
3989
+ // Debug builds only, and never the query string: stream URLs carry auth
3990
+ // tokens there, and logcat is readable by every app on older devices.
3991
+ if (!BuildConfig.DEBUG) return
3992
+ val cachedBytes = lookaheadCache?.cachedBytesReadTotal?.get() ?: 0L
3993
+ Log.i(
3994
+ DIAG_TAG,
3995
+ "now-playing source=$currentTrackSource cachedBytesRead=$cachedBytes " +
3996
+ "url=${url?.let(PlaybackErrorMapping::stripQuery)}"
3997
+ )
3998
+ }
3999
+
3591
4000
  override fun onCrossfadeCancel(engine: PlaybackEngine, revertedIndex: Int) {
3592
4001
  // Fade-cancelled mid-flight (pause / stop / focus loss / queue
3593
4002
  // mutation). JS already received `onTrackChange(incoming,
@@ -3601,10 +4010,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3601
4010
  lastErrorKey = null
3602
4011
  crossfadeFadeStartEmittedIdx = null
3603
4012
  val track = tracks[revertedIndex]
3604
- val cache = lookaheadCache
3605
- currentTrackSource = classifyTrackSource(track.url) { url ->
3606
- cache?.isFullyCached(url) == true
3607
- }
4013
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3608
4014
  pendingTrackChangeReason = null
3609
4015
  trackChangeListeners.forEach {
3610
4016
  it(track, revertedIndex.toDouble(), TrackChangeReason.CROSSFADE_CANCELLED, null)
@@ -3618,7 +4024,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3618
4024
  val now = System.nanoTime()
3619
4025
  gaplessGapPrevEndedAtNs = now
3620
4026
  if (BuildConfig.DEBUG) {
3621
- gaplessLogLastEndedAtNs = now
3622
4027
  Log.d(
3623
4028
  GAPLESS_LOG_TAG,
3624
4029
  "item-ended trackIdx=$currentTrackIndex ts_ns=$now"
@@ -3626,7 +4031,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3626
4031
  }
3627
4032
  }
3628
4033
 
3629
-
3630
4034
  /**
3631
4035
  * Translate Media3 playback state + playWhenReady + suppression
3632
4036
  * reason into our [PlayerState] enum and fire [stateChangeListeners]
@@ -3704,7 +4108,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3704
4108
  if (index == currentTrackIndex) return
3705
4109
  currentTrackIndex = index
3706
4110
  val track = tracks[index]
4111
+ // No engine call happens on this path, so nothing downstream
4112
+ // reclassifies the source or recomputes the skip capability the
4113
+ // way handleMediaItemTransition does for a local advance.
4114
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3707
4115
  trackChangeListeners.forEach { it(track, index.toDouble(), TrackChangeReason.AUTO_ADVANCE, null) }
4116
+ recomputeCapabilities()
3708
4117
  }
3709
4118
 
3710
4119
  /**
@@ -3724,6 +4133,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3724
4133
  fireEndOfTrackSleepTimer()
3725
4134
  queueEndListeners.forEach { it() }
3726
4135
  emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
4136
+ // Park the engine. Reaching the end of the queue is not the same as being
4137
+ // paused — `playWhenReady` stays set through STATE_ENDED — so a queue
4138
+ // installed afterwards would start playing without anyone asking. Repeat
4139
+ // modes wrap without ever reaching STATE_ENDED, so they are unaffected.
4140
+ serviceBinder?.engine?.pause()
4141
+ // The intent to play ends with the queue: nothing that seats a track from
4142
+ // here — a skip, a seek, a fresh queue, an engine swap — starts it until
4143
+ // play() is called again.
4144
+ wantsToPlay = false
4145
+ // The pause's inline callback stamps USER_REQUEST; ENDED is already
4146
+ // reported, so that emit deduplicates and the stamp would label the next
4147
+ // terminal emit a user action. Nobody asked for this pause.
4148
+ pendingStateChangeReason = null
3727
4149
  }
3728
4150
 
3729
4151
  /**
@@ -3740,6 +4162,16 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3740
4162
  * (user-skip), else fallback to [TrackChangeReason.SEEK]
3741
4163
  * - REASON_PLAYLIST_CHANGED → [TrackChangeReason.QUEUE_REPLACED]
3742
4164
  */
4165
+ /**
4166
+ * Whether playback of [url] is served from the disk cache: the cache
4167
+ * holds the whole file AND is enabled. Disabled, every read goes to the
4168
+ * origin whatever the disk holds, so a complete copy does not make the
4169
+ * track `CACHED`. An item whose data source opened before a toggle keeps
4170
+ * that source, so its report can differ from the flag for that one item.
4171
+ */
4172
+ private fun servedFromCache(url: String): Boolean =
4173
+ lookaheadConfig.enabled && lookaheadCache?.isFullyCached(url) == true
4174
+
3743
4175
  @VisibleForTesting
3744
4176
  internal fun handleMediaItemTransition(reason: Int) {
3745
4177
  val p = player ?: return
@@ -3758,10 +4190,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3758
4190
  // iOS reset in `handleCurrentItemDidChange`.
3759
4191
  lastErrorKey = null
3760
4192
  val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
3761
- val cache = lookaheadCache
3762
- currentTrackSource = classifyTrackSource(track?.url) { url ->
3763
- cache?.isFullyCached(url) == true
3764
- }
4193
+ currentTrackSource = classifyTrackSource(track?.url, ::servedFromCache)
3765
4194
 
3766
4195
  // Crossfade post-swap echo: fade-start already fired
3767
4196
  // `onCrossfadeBegin` which dispatched the JS-facing track-
@@ -3860,20 +4289,84 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3860
4289
  * reschedule the proactive prefetcher after a
3861
4290
  * queue/skip mutation. Reads `tracks` + `currentTrackIndex` (both
3862
4291
  * @Volatile) and dispatches the next N remote tracks to
3863
- * [LookaheadCacheWriter]. No-op when the writer hasn't been built
3864
- * (configure not yet called).
4292
+ * [LookaheadCacheWriter]. Always refreshes cache protection; the prefetch
4293
+ * dispatch is skipped when the writer hasn't been built (configure not yet
4294
+ * called, or prefetch disabled).
3865
4295
  */
3866
4296
  @MainThread
3867
4297
  private fun rescheduleLookahead() {
4298
+ // Ahead of the writer guard on purpose: the cache is built at configure()
4299
+ // regardless of `lookaheadConfig.enabled`, so a consumer with prefetch off
4300
+ // still has a live cache that evicts, and it would otherwise run with an
4301
+ // empty protected set.
4302
+ refreshCacheProtection()
3868
4303
  val writer = lookaheadCacheWriter ?: return
3869
4304
  writer.reschedule(tracks, currentTrackIndex)
3870
4305
  }
3871
4306
 
4307
+ /**
4308
+ * Tell the cache which entries eviction must not take: the playing track, the
4309
+ * tracks being prefetched ahead of it, and one behind for an immediate
4310
+ * skip-back.
4311
+ *
4312
+ * Bounded deliberately — protecting the whole remaining queue would leave
4313
+ * nothing evictable and turn each commit into a cache wipe.
4314
+ */
4315
+ @MainThread
4316
+ private fun refreshCacheProtection() {
4317
+ val cache = lookaheadCache ?: return
4318
+ // Under LRU the set is discarded, and this runs on every queue mutation.
4319
+ if (!cache.honoursProtection) return
4320
+ val cur = currentTrackIndex
4321
+ if (cur < 0 || cur >= tracks.size) {
4322
+ cache.setProtectedUrls(emptySet())
4323
+ return
4324
+ }
4325
+ // The count arrives from JS as a Double, so Infinity and NaN are reachable.
4326
+ // Both survive `toInt()` as Int.MAX_VALUE / 0, and `cur + Int.MAX_VALUE`
4327
+ // wraps negative, which would make the subList bounds throw on main.
4328
+ // Bounded by MAX_PROTECTED_AHEAD as well as by the queue: a consumer count
4329
+ // at or above the queue length would protect every entry, leaving the
4330
+ // evictor nothing to reclaim and the cache growing past its budget with no
4331
+ // ceiling. The window that matters is the one around the playhead.
4332
+ val ahead = lookaheadConfig.lookaheadCount
4333
+ .takeIf { it.isFinite() }
4334
+ ?.toInt()
4335
+ ?.coerceIn(0, minOf(tracks.size, MAX_PROTECTED_AHEAD))
4336
+ ?: 0
4337
+ val lower = (cur - 1).coerceAtLeast(0)
4338
+ val upper = (cur + ahead).coerceAtMost(tracks.size - 1)
4339
+ cache.setProtectedUrls(tracks.subList(lower, upper + 1).map { it.url }.toSet())
4340
+ }
4341
+
3872
4342
  private fun syncCurrentTrackIndexFromPlayer(p: ExoPlayer) {
3873
4343
  val playerIndex = p.currentMediaItemIndex
3874
4344
  currentTrackIndex = if (playerIndex in tracks.indices) playerIndex else -1
3875
4345
  }
3876
4346
 
4347
+ /** Scheduled auto-retry, held so teardown can cancel it. */
4348
+ private var pendingRetryRunnable: Runnable? = null
4349
+
4350
+ /**
4351
+ * Track index whose automatic attempt is scheduled but has not run yet. A
4352
+ * failure naming it is an echo of the one being retried, so it is not
4353
+ * reported while the attempt is still owed.
4354
+ */
4355
+ private var pendingRetryIndex: Int = -1
4356
+
4357
+ /**
4358
+ * Drop a scheduled automatic attempt.
4359
+ *
4360
+ * Called wherever the queue changes, because the attempt is keyed by
4361
+ * position: after a mutation that index names a different track, and both
4362
+ * the attempt itself and the echo suppression would then be aimed at it.
4363
+ */
4364
+ private fun cancelPendingRetry() {
4365
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4366
+ pendingRetryRunnable = null
4367
+ pendingRetryIndex = -1
4368
+ }
4369
+
3877
4370
  /**
3878
4371
  * Translate [PlaybackException] into a [PlaybackError] + emit on
3879
4372
  * both [errorListeners] and as a terminal ERROR state. Mirrors iOS
@@ -3881,27 +4374,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3881
4374
  * conditions surface as non-fatal so consumers can offer retry
3882
4375
  * UX; codec / decoding errors surface as fatal.
3883
4376
  */
3884
- private fun handlePlayerError(error: PlaybackException) {
4377
+ private fun handlePlayerError(error: PlaybackException, failedIndex: Int = -1) {
3885
4378
  val mapped = PlaybackErrorMapping.classify(error)
3886
- // Dedup by (currentTrackIndex, standardized code). Mirrors iOS
4379
+ // Attribute to the track that actually failed. Under crossfade the
4380
+ // standby leg can fail while prerolling an upcoming track, and the
4381
+ // leading leg plays on — keying any of the three uses below off
4382
+ // currentTrackIndex would dedup against the wrong track, spend the wrong
4383
+ // track's retry budget, and report the wrong url to the consumer.
4384
+ val idx = if (failedIndex in tracks.indices) failedIndex else currentTrackIndex
4385
+ // Dedup by (failing index, standardized code). Mirrors iOS
3887
4386
  // `(queueItemId, code)` dedup. ExoPlayer can fire onPlayerError
3888
4387
  // multiple times for a single failed item under retry storms.
3889
- val key = currentTrackIndex to mapped
4388
+ val key = idx to mapped
4389
+ // An echo of a failure whose attempt has not run yet belongs to that
4390
+ // attempt, not to the consumer.
4391
+ if (idx >= 0 && idx == pendingRetryIndex) return
3890
4392
  if (lastErrorKey == key) return
3891
4393
  lastErrorKey = key
3892
4394
  // Auto-retry path: transient errors with retries left
3893
4395
  // schedule a delayed `prepare()`. Skip the JS error emit + state
3894
4396
  // ERROR transition until retries exhausted; consumer sees onError
3895
4397
  // only when the lib has given up. Mirrors iOS path.
3896
- val idx = currentTrackIndex
4398
+ // Only the track being played is retried. A standby leg's preroll failing
4399
+ // is the *next* track failing to load early, and the cancel that precedes
4400
+ // the report reverts `currentTrackIndex` to the leading track, so an
4401
+ // attempt scheduled for it could never run. Surfacing it instead is both
4402
+ // honest and harmless: when that track becomes current it is loaded fresh
4403
+ // and gets its own automatic attempt then.
3897
4404
  if (idx >= 0 &&
4405
+ idx == currentTrackIndex &&
3898
4406
  PlaybackErrorMapping.isTransient(mapped) &&
3899
4407
  (retryAttemptsRemaining[idx] ?: 0) > 0) {
3900
4408
  retryAttemptsRemaining[idx] = retryAttemptsRemaining[idx]!! - 1
3901
4409
  // Clear dedup so the retry-then-fail (if any) fires fresh.
3902
4410
  lastErrorKey = null
3903
4411
  val backoff = effectiveRetryBackoffMs()
3904
- mainHandler.postDelayed({ retryFailedItem(idx) }, backoff.toLong())
4412
+ // Hold the runnable so teardown can drop it. A retry posted moments
4413
+ // before destroy() would otherwise fire against a released engine.
4414
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4415
+ val retry = Runnable { retryFailedItem(idx) }
4416
+ pendingRetryRunnable = retry
4417
+ pendingRetryIndex = idx
4418
+ mainHandler.postDelayed(retry, backoff.toLong())
3905
4419
  return
3906
4420
  }
3907
4421
  // Surface the failing track's URL so consumers can correlate the
@@ -3915,16 +4429,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3915
4429
  error, mapped, queueItemId = "", url = failedUrl,
3916
4430
  )
3917
4431
  errorListeners.forEach { it(err) }
3918
- emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4432
+ // The state is the current track's. A standby leg's preroll failing is
4433
+ // reported above and leaves the leading leg playing; announcing ERROR for
4434
+ // it would put a terminal state under audio that is still running, and
4435
+ // the next engine callback would flip it straight back to PLAYING.
4436
+ if (idx == currentTrackIndex) {
4437
+ emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4438
+ }
3919
4439
  }
3920
4440
 
3921
4441
  // --- Auto-retry + stuck-recovery
3922
4442
 
3923
4443
  /** Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
3924
- * defaults to 3, clamps to [0, 5]. */
4444
+ * defaults to 1, clamps to [0, 5]. */
3925
4445
  @VisibleForTesting
3926
4446
  internal fun effectiveAutoRetries(): Int =
3927
- (config.autoRetries?.toInt() ?: 3).coerceIn(0, 5)
4447
+ (config.autoRetries?.toInt() ?: 1).coerceIn(0, 5)
3928
4448
 
3929
4449
  /** Lib-clamped retry backoff in milliseconds. Defaults to 500,
3930
4450
  * clamps to [200, 5000]. Floor at 200ms prevents retry storms
@@ -3935,28 +4455,41 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3935
4455
 
3936
4456
  /** Try to recover from a transient failure by re-preparing the
3937
4457
  * ExoPlayer at the current position. Called from
3938
- * [handlePlayerError] (auto-retry) AND from [play] (one-shot
3939
- * stuck-recovery). Mirrors iOS `retryFailedItem` which does a
3940
- * full AVQueuePlayer rebuild via `fullRebuildPlayerQueue`.
4458
+ * [handlePlayerError] (auto-retry) and from [retry] (the
4459
+ * consumer-facing one-shot). Mirrors iOS `retryFailedItem`, which
4460
+ * rebuilds the AVQueuePlayer via `fullRebuildPlayerQueue` and seeks
4461
+ * back to the captured position.
3941
4462
  *
3942
- * Implementation: `setMediaItem(currentMediaItem, false) +
3943
- * prepare()` rather than bare `prepare()` — the latter re-uses
3944
- * the existing wedged DataSource on a "Cannot Open"-class
3945
- * failure and may not actually recover. Re-setting the
3946
- * MediaItem forces Media3 to construct a fresh DataSource from
3947
- * the MediaSource.Factory (carries the current `httpFactory`
3948
- * + cache wrapper). `resetPosition=false` preserves the
3949
- * current playback position; iOS rebuild resets to 0 (mid-play
3950
- * failure loses position regardless). For initial-load failures
3951
- * the position is 0 anyway, so the position-preservation here
3952
- * is the only intentional iOS/Android divergence. */
4463
+ * Implementation: remove + re-add the failed item at its index,
4464
+ * `seekTo` the captured position, then `prepare()` — rather than
4465
+ * bare `prepare()`, which re-uses the existing wedged DataSource
4466
+ * on a "Cannot Open"-class failure and may not actually recover.
4467
+ * Re-adding the item forces Media3 to construct a fresh DataSource
4468
+ * from the MediaSource.Factory (carries the current `httpFactory`
4469
+ * + cache wrapper). */
3953
4470
  internal fun retryFailedItem(atIndex: Int) {
3954
- val p = player ?: return
3955
- // Verify the failed item is still the current one. This also bounds the
3956
- // rebuild to the leading leg's active item: mid-fade the leading leg is
3957
- // the OUTGOING track, whose index differs from currentTrackIndex (the
3958
- // fade-flipped incoming track), so a retry mid-fade is skipped here.
4471
+ // The attempt has arrived, so whatever fails next is a new failure rather
4472
+ // than an echo of the one being retried. A scheduled attempt for the same
4473
+ // index is withdrawn with it: left posted, it would fire after the backoff
4474
+ // and rebuild the track a second time behind a user's own retry.
4475
+ if (pendingRetryIndex == atIndex) {
4476
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4477
+ pendingRetryIndex = -1
4478
+ pendingRetryRunnable = null
4479
+ }
4480
+ // The failed item must still be the current one; a skip or a queue mutation
4481
+ // since the failure makes the retry moot. Checked before the fade is
4482
+ // settled, because settling is an audible cut and a retry that is about to
4483
+ // return has no business making one.
3959
4484
  if (atIndex != currentTrackIndex) return
4485
+ // Settle a fade before reading the player. `player` is the leading leg,
4486
+ // which mid-fade holds the OUTGOING track while `currentTrackIndex` has
4487
+ // already flipped to the incoming one — so every read below would describe
4488
+ // a different track than the index check just approved, and a retry would
4489
+ // rebuild and restart the track the user is leaving. Promoting first makes
4490
+ // the leading leg the track being heard, which is the one a retry is for.
4491
+ serviceBinder?.engine?.settlePendingFadeByPromoting()
4492
+ val p = player ?: return
3960
4493
  val current = p.currentMediaItem
3961
4494
  if (current != null) {
3962
4495
  val idx = p.currentMediaItemIndex
@@ -3978,10 +4511,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3978
4511
  } else {
3979
4512
  p.prepare()
3980
4513
  }
3981
- // Resume playback if the user had been playing pre-failure.
3982
- // (Live-state check; the pre-failure playWhenReady wasn't
3983
- // captured)
3984
- if (!p.isPlaying) serviceBinder?.engine?.play()
4514
+ // Resume only if playback is wanted NOW, not at failure time: a manual
4515
+ // retry() carries no failure-time flag, and a pause during the retry
4516
+ // backoff must win over the state the failure was scheduled under.
4517
+ if (wantsToPlay && !p.isPlaying) serviceBinder?.engine?.play()
3985
4518
  }
3986
4519
 
3987
4520
  /**
@@ -4244,13 +4777,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4244
4777
  if (Looper.myLooper() == Looper.getMainLooper()) {
4245
4778
  body()
4246
4779
  } else {
4247
- val latch = CountDownLatch(1)
4780
+ val done = CountDownLatch(1)
4248
4781
  var err: Throwable? = null
4249
4782
  mainHandler.post {
4250
4783
  try { body() } catch (t: Throwable) { err = t }
4251
- latch.countDown()
4784
+ done.countDown()
4252
4785
  }
4253
- latch.await()
4786
+ done.await()
4254
4787
  err?.let { throw it }
4255
4788
  }
4256
4789
  }
@@ -4275,18 +4808,45 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4275
4808
  }
4276
4809
 
4277
4810
  companion object {
4811
+ /** Prefs holding the eviction policy the on-disk cache was built with. */
4812
+ private const val CACHE_PREFS_NAME = "rnqp_lookahead_cache"
4813
+ private const val KEY_EVICTION_POLICY = "eviction_policy"
4814
+
4815
+ /** Marks a cache directory renamed aside and awaiting deletion. */
4816
+ private const val DELETING_DIR_SUFFIX = ".deleting-"
4817
+
4818
+ /**
4819
+ * Renamed cache directories this process is currently deleting.
4820
+ *
4821
+ * The sweep collects anything wearing the deleting- prefix, which is
4822
+ * exactly the name a discard just produced — so without this it starts a
4823
+ * second delete of a tree already being deleted, with a second database
4824
+ * provider dropping the same index tables underneath the first.
4825
+ *
4826
+ * Process-wide because the cache is: a second [TrackPlayer] configured in
4827
+ * the same process sweeps the same directory. Held only while a delete is
4828
+ * in flight, so a directory abandoned by a previous process — the case the
4829
+ * sweep exists for — is never claimed and is always collected.
4830
+ */
4831
+ private val doomedCacheDirectories = java.util.Collections.synchronizedSet(
4832
+ mutableSetOf<String>())
4833
+
4834
+ private fun claimDoomedDirectory(dir: java.io.File) {
4835
+ doomedCacheDirectories.add(dir.absolutePath)
4836
+ }
4837
+
4838
+ private fun releaseDoomedDirectory(dir: java.io.File) {
4839
+ doomedCacheDirectories.remove(dir.absolutePath)
4840
+ }
4841
+
4842
+ private fun isDoomedDirectoryClaimed(dir: java.io.File): Boolean =
4843
+ doomedCacheDirectories.contains(dir.absolutePath)
4844
+
4278
4845
  /** Progress tick cadence — matches iOS 2 Hz. */
4279
4846
  private const val PROGRESS_TICK_INTERVAL_MS = 500L
4280
4847
  /** Sleep-timer countdown/fade cadence — 500ms gives a smooth 10s fade. */
4281
4848
  private const val SLEEP_TIMER_TICK_INTERVAL_MS = 500L
4282
4849
 
4283
- /**
4284
- * Disposer returned from `on*Request` registrations when the
4285
- * `PlaybackService` isn't bound yet. Calling it is a no-op —
4286
- * the registration never landed, so there's nothing to clear.
4287
- */
4288
- private val NO_OP_DISPOSER: () -> Unit = {}
4289
-
4290
4850
  /** Tolerance for "bufferedPosition has reached duration" — a CBR-estimated
4291
4851
  * duration or rounding can leave a sub-second gap when fully downloaded. */
4292
4852
  private const val FULLY_BUFFERED_EPSILON_MS = 1000L
@@ -4296,6 +4856,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4296
4856
  * reference one named constant. */
4297
4857
  private const val MS_PER_SECOND = 1000.0
4298
4858
 
4859
+ /**
4860
+ * Upper bound on how far ahead of the playhead cache eviction is blocked.
4861
+ *
4862
+ * Independent of the consumer's lookahead count on purpose: protecting the
4863
+ * whole queue leaves the evictor nothing to reclaim, and the cache then
4864
+ * grows past its budget with no ceiling.
4865
+ */
4866
+ private const val MAX_PROTECTED_AHEAD = 10
4867
+
4299
4868
  /**
4300
4869
  * Tag for the verbose gapless-transition logs (item-ended /
4301
4870
  * item-changed / playing). All emit sites are guarded by
@@ -4304,6 +4873,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4304
4873
  */
4305
4874
  private const val GAPLESS_LOG_TAG = "RNQP-GAPLESS"
4306
4875
 
4876
+ /** Playback-source diagnostics: what the player actually reads from. */
4877
+ private const val DIAG_TAG = "RNQP-DIAG"
4878
+
4307
4879
  /** Logcat tag for player-level lifecycle warnings. Part of the
4308
4880
  * `QueuePlayer.<area>` hierarchy used across the lib's Android
4309
4881
  * sources. */
@@ -4323,6 +4895,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4323
4895
  var current: TrackPlayer? = null
4324
4896
  private set
4325
4897
 
4898
+ init {
4899
+ // One process-wide listener on the cast route, forwarding to whichever
4900
+ // instance is configured. Push-PCM sessions (AirPlay 1/2) force-degrade
4901
+ // the active engine from Crossfade to Gapless because the wire protocol
4902
+ // cannot mix two ExoPlayers into one sink; clearing the session
4903
+ // re-applies the user's requested mode (so crossfade re-instates).
4904
+ // Registered once here rather than per instance: `CastSinkRouter` is a
4905
+ // process-wide object, and a per-instance listener kept every instance
4906
+ // ever constructed reachable and ran the swap on all of them.
4907
+ com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
4908
+ val player = current ?: return@addRouteChangeListener
4909
+ // Re-apply against the user's requested mode on every flip.
4910
+ // `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
4911
+ // for the degrade decision, so the swap settles to the right
4912
+ // engine for the new route state.
4913
+ val mode = player.playbackModeState.active
4914
+ player.mainHandler.post { player.applyEngineSwapForMode(mode) }
4915
+ }
4916
+ }
4917
+
4326
4918
  /**
4327
4919
  * Classify the active-track playback source. `null` when the URL
4328
4920
  * is null or its scheme is neither `file://` nor `http(s)://`