react-native-queue-player 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +111 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +78 -30
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +13 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +985 -276
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  61. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +84 -18
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +122 -38
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +291 -27
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  114. package/app.plugin.js +17 -1
  115. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  116. package/ios/AVQueueBuilder.swift +120 -85
  117. package/ios/ArtworkLoader.swift +0 -7
  118. package/ios/ArtworkResolver.swift +22 -4
  119. package/ios/AssetReplayGainReader.swift +118 -0
  120. package/ios/AudioSession.swift +67 -16
  121. package/ios/AudioTapProvider.swift +284 -149
  122. package/ios/CarPlayBridge.swift +0 -12
  123. package/ios/CarPlayCoordinator.swift +18 -2
  124. package/ios/CarPlaySceneDelegate.swift +2 -2
  125. package/ios/Cast/AirPlayRouteState.swift +9 -8
  126. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  127. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  128. package/ios/Cast/Core/CastSession.swift +2 -2
  129. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  130. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  131. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  132. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  133. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  134. package/ios/CastManager.swift +1 -1
  135. package/ios/CrossfadeEngine.swift +672 -226
  136. package/ios/EQTap.swift +20 -17
  137. package/ios/Equalizer.swift +1 -1
  138. package/ios/FLACStreamInfo.swift +65 -0
  139. package/ios/GaplessEngine.swift +57 -30
  140. package/ios/InputGuards.swift +35 -5
  141. package/ios/LookaheadCache.swift +229 -58
  142. package/ios/LookaheadCachePrefetcher.swift +48 -36
  143. package/ios/MediaResponseCheck.swift +122 -0
  144. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  145. package/ios/NetworkRecoveryPolicy.swift +32 -0
  146. package/ios/NowPlayingFormatExtractor.swift +25 -18
  147. package/ios/NowPlayingInfo.swift +84 -48
  148. package/ios/OutputRouteMonitor.swift +16 -3
  149. package/ios/PendingBrowseRequests.swift +1 -1
  150. package/ios/PlaceholderArtwork.swift +9 -5
  151. package/ios/PlaybackEngine.swift +165 -76
  152. package/ios/PlaybackErrorMapping.swift +3 -3
  153. package/ios/PlaybackModeStateMachine.swift +2 -2
  154. package/ios/PlaybackNetworkMonitor.swift +73 -0
  155. package/ios/PlayerStateDerivation.swift +6 -6
  156. package/ios/QueueMutationArithmetic.swift +25 -0
  157. package/ios/QueueSkipArithmetic.swift +5 -3
  158. package/ios/QueueWindowArithmetic.swift +152 -0
  159. package/ios/ReadThroughServer.swift +283 -0
  160. package/ios/RemoteCommands.swift +44 -4
  161. package/ios/ReplayGainData.swift +22 -3
  162. package/ios/ReplayGainExtractor.swift +70 -27
  163. package/ios/Siri/VoiceDonation.swift +14 -9
  164. package/ios/StreamingBitrateProbe.swift +22 -68
  165. package/ios/SuppliedReplayGain.swift +83 -0
  166. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  167. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  168. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  169. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  170. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  171. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  172. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  173. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  174. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  175. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  176. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  177. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  178. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  179. package/ios/Tests/EngineSwapTests.swift +91 -0
  180. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  181. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  182. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  183. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  184. package/ios/Tests/InputGuardsTests.swift +41 -0
  185. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  186. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  187. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  188. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  189. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  190. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  191. package/ios/Tests/MediaResponseCheckTests.swift +128 -0
  192. package/ios/Tests/MutationDeferralTests.swift +220 -0
  193. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  194. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  195. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  196. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  197. package/ios/Tests/PlayerFixtures.swift +48 -0
  198. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  199. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  200. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  201. package/ios/Tests/QueueStateTests.swift +3 -1
  202. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  203. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  204. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  205. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  206. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  207. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  208. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  209. package/ios/Tests/RetryRecoveryTests.swift +418 -0
  210. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  211. package/ios/Tests/SkipIndexTests.swift +32 -2
  212. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  213. package/ios/Tests/StallRecoveryTests.swift +97 -0
  214. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  215. package/ios/Tests/TopUpWindowGateTests.swift +441 -0
  216. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  217. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  218. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  219. package/ios/Tests/TrackPlayerEndVerdictTests.swift +206 -0
  220. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  221. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  222. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  223. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  224. package/ios/TrackPlayer+Automotive.swift +101 -0
  225. package/ios/TrackPlayer+Cache.swift +232 -0
  226. package/ios/TrackPlayer+Config.swift +291 -0
  227. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  228. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  229. package/ios/TrackPlayer+EventsDispatch.swift +1042 -0
  230. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  231. package/ios/TrackPlayer+Lifecycle.swift +982 -0
  232. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  233. package/ios/TrackPlayer+Queue.swift +951 -0
  234. package/ios/TrackPlayer+Recovery.swift +213 -0
  235. package/ios/TrackPlayer+Skip.swift +465 -0
  236. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  237. package/ios/TrackPlayer+State.swift +108 -0
  238. package/ios/TrackPlayer+Threading.swift +135 -0
  239. package/ios/TrackPlayer+Transport.swift +272 -0
  240. package/ios/TrackPlayer+Window.swift +227 -0
  241. package/ios/TrackPlayer.swift +331 -4619
  242. package/ios/Visualizer.swift +6 -5
  243. package/ios/tests-harness/Podfile +1 -1
  244. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  245. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  246. package/lib/module/hooks/useActiveTrack.js +29 -22
  247. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  248. package/lib/module/hooks/useCast.js +8 -26
  249. package/lib/module/hooks/useCast.js.map +1 -1
  250. package/lib/module/hooks/useEqualizer.js +19 -12
  251. package/lib/module/hooks/useEqualizer.js.map +1 -1
  252. package/lib/module/hooks/useLookaheadCache.js +3 -7
  253. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  254. package/lib/module/hooks/useQueue.js +66 -19
  255. package/lib/module/hooks/useQueue.js.map +1 -1
  256. package/lib/module/index.js +6 -3
  257. package/lib/module/index.js.map +1 -1
  258. package/lib/module/queueDelta.js +41 -0
  259. package/lib/module/queueDelta.js.map +1 -0
  260. package/lib/module/types.js +28 -5
  261. package/lib/module/types.js.map +1 -1
  262. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  263. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  265. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  266. package/lib/typescript/hooks/useCast.d.ts +6 -1
  267. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  269. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  271. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  272. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  273. package/lib/typescript/index.d.ts +2 -1
  274. package/lib/typescript/index.d.ts.map +1 -1
  275. package/lib/typescript/queueDelta.d.ts +10 -0
  276. package/lib/typescript/queueDelta.d.ts.map +1 -0
  277. package/lib/typescript/types.d.ts +168 -12
  278. package/lib/typescript/types.d.ts.map +1 -1
  279. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  280. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  281. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  282. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  283. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  284. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  285. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  320. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  321. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  322. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  323. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  324. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  325. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  326. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  327. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  328. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  329. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  330. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  331. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  332. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  333. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  334. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  335. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  336. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  337. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  338. package/package.json +8 -8
  339. package/src/TrackPlayer.nitro.ts +53 -27
  340. package/src/hooks/useActiveTrack.ts +29 -22
  341. package/src/hooks/useCast.ts +14 -15
  342. package/src/hooks/useEqualizer.ts +19 -12
  343. package/src/hooks/useLookaheadCache.ts +3 -7
  344. package/src/hooks/useQueue.ts +66 -17
  345. package/src/index.ts +13 -3
  346. package/src/queueDelta.ts +41 -0
  347. package/src/types.ts +169 -12
  348. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  349. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  350. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  351. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  352. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  353. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  354. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  355. package/ios/MetadataReader.swift +0 -555
  356. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  357. package/ios/Tests/MetadataReaderTests.swift +0 -603
  358. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  359. package/ios/Tests/VoiceDonationTests.swift +0 -26
  360. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  361. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  362. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -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().
@@ -694,15 +840,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
694
840
  * initiation so the retry-then-fail can fire a fresh error. */
695
841
  private var lastErrorKey: Pair<Int, PlaybackErrorCode>? = null
696
842
 
843
+ /**
844
+ * Positions whose playback error has been reported to the consumer and not
845
+ * recovered since. Empty when nothing is outstanding, and `-1` is never a
846
+ * member — a failure carrying no track records nothing, which is what keeps
847
+ * `currentTrackIndex in this` from reading as stopped while no track is
848
+ * seated.
849
+ *
850
+ * A set, not one position: under crossfade the standby leg prerolls the next
851
+ * track, so a broken one is reported while the leading track still plays and
852
+ * both are outstanding at once. Holding only the latest would hand the
853
+ * listener the standby's failure a second time when the engine seats it.
854
+ * The queue is stopped when `currentTrackIndex` is a member.
855
+ *
856
+ * Written only where [handlePlayerError] hands an `onError` out, so it names
857
+ * a track the consumer has been told about and nothing has recovered.
858
+ * [retryFailedItem] and every skip drop the entry for the position they
859
+ * attempt; a track change keeps only the announced position's; `setQueue`,
860
+ * `clearQueue`, `destroy` and `shuffleQueue` empty it, and `addToQueue`,
861
+ * `removeFromQueue` and `moveInQueue` keep only the current track's, carried
862
+ * to its new index. `onCrossfadeCancel` deliberately keeps the set: it
863
+ * returns to the track already playing rather than announcing a new one, and
864
+ * the prerolled track is still queued and still broken. Deliberately NOT a
865
+ * PLAYING emit: a source the player keeps re-attempting flips to playing
866
+ * between attempts, so clearing there would reopen a track's report.
867
+ *
868
+ * Separate from [lastErrorKey] even though both name a failing track: that
869
+ * one exists to dedup repeat reports and is cleared whenever a repeat would
870
+ * be wanted again, which is not the same question as whether the queue is
871
+ * still stopped. Mirrors the iOS `reportedFailureQueueItemIds` set.
872
+ */
873
+ private val reportedFailureIndices = mutableSetOf<Int>()
874
+
697
875
  /** Auto-retry counter per queue position, keyed on
698
876
  * `currentTrackIndex` (lib-internal authoritative position; never
699
877
  * derived from consumer fields). Decremented on each
700
878
  * transient-error fire; when 0, the error surfaces to JS.
701
879
  * Re-populated on `setQueueInternal` /
702
880
  * `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`
881
+ * 0..5; default 1). Restored to the configured value by `retry()`, so
882
+ * a manual attempt gives whatever fails next its configured automatic
883
+ * allowance. `play()` restores the same configured value when the counter
884
+ * is 0 — stuck-recovery on a user re-tap. Cleared on `removeFromQueue` / `moveInQueue`
706
885
  * (index shifts invalidate keys; budget resets are an acceptable
707
886
  * trade-off vs walking + remapping the map per shifted index). */
708
887
  private var retryAttemptsRemaining: MutableMap<Int, Int> = mutableMapOf()
@@ -874,25 +1053,34 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
874
1053
  internal fun configureInternal(newConfig: PlayerConfig, context: Context) {
875
1054
  // Eviction policy is fixed at SimpleCache construction; a change clears the
876
1055
  // 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)
1056
+ // The service still holds the engine `configurePlayer` replaces further
1057
+ // down — `destroyInternal` drops this player's references, not the
1058
+ // service's engine and that engine reads through the shared cache, so it
1059
+ // is stopped before the cache is released and its directory renamed away.
1060
+ // Compare against what was last APPLIED, not against the in-memory config.
1061
+ // A fresh process starts at the LRU default, so a FIFO consumer would
1062
+ // otherwise read every cold launch as a policy change and wipe its cache
1063
+ // before it could ever be used.
1064
+ val previousEvictionPolicy =
1065
+ persistedEvictionPolicy(context) ?: evictionPolicyFrom(this.config)
882
1066
  this.config = newConfig
883
1067
  // Swap the cover-art placeholder bitmap to the consumer-supplied image
884
1068
  // (or back to the built-in when unset). The no-art metadata URI is
885
1069
  // threaded separately at each buildMediaItems call.
886
1070
  PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
887
1071
  if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
1072
+ serviceBinder?.engine?.stop()
888
1073
  lookaheadCacheWriter?.release()
889
1074
  lookaheadCacheWriter = null
890
1075
  lookaheadCache?.release()
891
1076
  lookaheadCache = null
892
- val cacheDir = java.io.File(context.cacheDir, LookaheadCache.DEFAULT_CACHE_DIR_NAME)
893
- LookaheadCache.releaseSharedInstance(cacheDir)
894
- cacheDir.deleteRecursively()
1077
+ cellularTransportMonitor?.stop()
1078
+ cellularTransportMonitor = null
1079
+ discardCacheDirectory(context)
895
1080
  }
1081
+ // Collect what a previous process left mid-delete. Directories this
1082
+ // process is still deleting are claimed and skipped.
1083
+ sweepAbandonedCacheDirectories(context)
896
1084
  // Seed the progress-emission throttle from config and start observing app
897
1085
  // background/foreground. Both are idempotent, so a re-configure refreshes
898
1086
  // the rate without double-registering the lifecycle observer.
@@ -907,6 +1095,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
907
1095
  // new field.
908
1096
  VisualizationGate.setEnabled(newConfig.visualizationEnabled ?: true)
909
1097
  buildLookaheadStack(newConfig, context)
1098
+ // Recorded only once a cache actually exists under this policy, so a failed
1099
+ // build cannot leave a policy stamped that nothing was built with.
1100
+ if (lookaheadCache != null) {
1101
+ persistEvictionPolicy(context, evictionPolicyFrom(newConfig))
1102
+ }
1103
+ // configure() destroys first, so the queue is empty here and this clears
1104
+ // protection rather than seeding it. That is the point: the evictor lives
1105
+ // in a process-wide shared instance that outlives a destroy/configure
1106
+ // cycle, so without this it would keep shielding the previous session's
1107
+ // tracks until the next queue mutation.
1108
+ refreshCacheProtection()
910
1109
  val mediaSourceFactory =
911
1110
  MediaItemBuilder.buildMediaSourceFactory(context, newConfig, lookaheadCache)
912
1111
  val binder = serviceBinder
@@ -943,6 +1142,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
943
1142
  engine.setVolume(volumeState)
944
1143
  engine.setPlaybackSpeed(playbackSpeedState)
945
1144
  engine.setPitchCorrectionMode(pitchCorrectionModeState)
1145
+ installSuppliedReplayGainResolver(engine)
946
1146
  }
947
1147
  installEventObservers()
948
1148
  binder.setCommandHandler(mediaSessionCommandHandler)
@@ -950,7 +1150,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
950
1150
  }
951
1151
 
952
1152
  /**
953
- * Bridge from [PlaybackService.PlaybackCallback] back into this
1153
+ * Bridge from `PlaybackService` its [SessionCommandForwardingPlayer],
1154
+ * [MediaButtonDispatch] and audio-focus listener — back into this
954
1155
  * TrackPlayer. Stamps the `system` pending-reason for transports
955
1156
  * that the system surface drives (lock-screen, Bluetooth, Android
956
1157
  * Auto, hardware media keys), and routes skip-next / skip-previous
@@ -1089,6 +1290,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1089
1290
  * not a correctness gate.
1090
1291
  */
1091
1292
  private fun buildLookaheadStack(config: PlayerConfig, context: Context) {
1293
+ // Ahead of the writer, which captures the monitor's reading at every
1294
+ // reschedule, and a transport flip has to move the window itself. Built
1295
+ // regardless of `enabled` for the same reason the cache is: prefetch can
1296
+ // be turned on at runtime, and the writer built then needs a monitor.
1297
+ cellularTransportMonitor?.stop()
1298
+ cellularTransportMonitor = CellularTransportMonitor(context).apply {
1299
+ // The callback arrives on a ConnectivityManager thread; the reschedule
1300
+ // is main-confined.
1301
+ onTransportChanged = { mainHandler.post { rescheduleLookahead() } }
1302
+ start()
1303
+ }
1092
1304
  // Build the cache regardless of `enabled` so caching can be turned on at
1093
1305
  // runtime (setLookaheadCacheInternal) without a configure() cycle. Media3's
1094
1306
  // SimpleCache fixes its size at construction and allows one instance per
@@ -1118,6 +1330,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1118
1330
  null
1119
1331
  }
1120
1332
  lookaheadCache = cache
1333
+ cache?.servesPlayback = lookaheadConfig.enabled
1121
1334
  // Writer + player factory share state via the cache, not via the
1122
1335
  // http factory instance. Writer is only useful when there's a
1123
1336
  // cache to write into — gate on both `lookaheadConfig.enabled`
@@ -1131,10 +1344,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1131
1344
  networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
1132
1345
  config.networkTimeoutMs
1133
1346
  )
1134
- )
1347
+ ),
1348
+ isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
1135
1349
  ).apply {
1136
1350
  defaultLookaheadCount = lookaheadConfig.lookaheadCount.toInt()
1137
1351
  .coerceAtLeast(0)
1352
+ allowsCellularAccess = lookaheadConfig.allowsCellularAccess ?: true
1138
1353
  onTrackProcessed = { onMainPostStatusEmit() }
1139
1354
  }
1140
1355
  } else {
@@ -1142,20 +1357,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1142
1357
  }
1143
1358
  }
1144
1359
 
1145
- /**
1146
- * Tear down the ExoPlayer + lookahead writer + cache instances so
1147
- * the configure() rebuild path (headers/userAgent change) can
1148
- * recreate them cleanly. The on-disk cache directory is preserved
1149
- * — `SimpleCache.release()` only frees the in-memory instance and
1150
- * its file lock; the entries stay on disk and the next
1151
- * `LookaheadCache(...)` constructor reads them back. Mirrors the
1152
- * SimpleCache "one-instance-per-directory" invariant captured in
1153
- * NOTES.md.
1154
- */
1155
- private fun destroyPlayerKeepCacheOnDisk() {
1156
- releasePlayerAndState(notifyTrackChange = true)
1157
- }
1158
-
1159
1360
  /**
1160
1361
  * Lifecycle destroy body. Releases the ExoPlayer instance and
1161
1362
  * resets every piece of mutable state so a subsequent configure()
@@ -1171,7 +1372,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1171
1372
  // player.
1172
1373
  cancelPendingRebind()
1173
1374
  serviceBinder?.setCommandHandler(null)
1174
- releasePlayerAndState(notifyTrackChange = false)
1375
+ releasePlayerAndState()
1175
1376
  boundContext?.let { ctx ->
1176
1377
  try {
1177
1378
  ctx.unbindService(serviceConnection)
@@ -1200,25 +1401,23 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1200
1401
  pendingStateChangeReason = null
1201
1402
  pendingTrackChangeReason = null
1202
1403
  lastErrorKey = null
1404
+ reportedFailureIndices.clear()
1405
+ cancelPendingRetry()
1203
1406
  retryAttemptsRemaining.clear()
1204
1407
  if (current === this) current = null
1205
1408
  }
1206
1409
 
1207
1410
  /**
1208
- * Shared tear-down for the player + cache + writer + per-position
1209
- * state. Tear order matters: prefetcher releases before the cache
1210
- * so an in-flight `CacheWriter` reading from a released SimpleCache
1211
- * cannot NPE. SimpleCache enforces a one-instance-per-directory
1212
- * invariant for the JVM lifetime, so the in-memory cache object is
1213
- * released here even when the on-disk directory is preserved for
1214
- * a follow-up `configureInternal` to re-open. When
1215
- * `notifyTrackChange` is true, fires a synthetic
1216
- * `(null, -1, QUEUE_REPLACED)` so JS hooks like `useActiveTrack`
1217
- * clear their snapshot — without it a Player UI rendering
1218
- * `#${index+1}/${queueLength}` would show `#1/0` after the queue
1219
- * empties.
1411
+ * Tear-down for the writer, the cache reference and per-position
1412
+ * state, called from [destroyInternal]. The writer's coroutine scope
1413
+ * is cancelled before the cache's MIME store is cleared so a racing
1414
+ * `recordMimeType` cannot land after the clear. The Media3 SimpleCache
1415
+ * is a per-folder process singleton and is not released here
1416
+ * `LookaheadCache.release()` drops only per-instance state; an
1417
+ * eviction-policy change in [configureInternal] releases and rebuilds
1418
+ * it via [discardCacheDirectory].
1220
1419
  */
1221
- private fun releasePlayerAndState(notifyTrackChange: Boolean) {
1420
+ private fun releasePlayerAndState() {
1222
1421
  // Cancel any armed sleep timer up front — the engine (and its volume) is
1223
1422
  // going away, so don't touch it or emit; just stop the tick + reset state.
1224
1423
  stopSleepTimerTick()
@@ -1228,16 +1427,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1228
1427
  tearDownEventObservers()
1229
1428
  lookaheadCacheWriter?.release()
1230
1429
  lookaheadCacheWriter = null
1430
+ // Before dropping the reference: the evictor is process-wide and outlives
1431
+ // this player, so its protected set has to be cleared here or those entries
1432
+ // stay unevictable until the next configure moves the window.
1433
+ // `LookaheadCache.release()` does not touch it.
1434
+ lookaheadCache?.setProtectedUrls(emptySet())
1231
1435
  lookaheadCache?.release()
1232
1436
  lookaheadCache = null
1437
+ cellularTransportMonitor?.stop()
1438
+ cellularTransportMonitor = null
1233
1439
  // Mark unconfigured so the [player] accessor returns null even
1234
1440
  // though the service may still be bound (it unbinds at the
1235
1441
  // bottom of destroyInternal). The PlaybackService releases its
1236
1442
  // own engine (which owns the ExoPlayer instances) on service
1237
1443
  // unbind / onDestroy.
1238
1444
  configured = false
1239
- val hadTracks = tracks.isNotEmpty()
1240
1445
  tracks = emptyList()
1446
+ // The clear emits nothing, so a mirror built from `onQueueChange` deltas
1447
+ // would splice the next delta onto the queue this just dropped. Moving
1448
+ // the revision past the next emit's value is what a subscriber reads as
1449
+ // a missed event, and re-reads.
1450
+ queueRevision += 1
1241
1451
  currentTrackIndex = -1
1242
1452
  currentTrackSource = null
1243
1453
  currentBufferState = BufferState.EMPTY
@@ -1247,9 +1457,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1247
1457
  hasStartedPlaying = false
1248
1458
  wantsToPlay = false
1249
1459
  lastReportedState = PlayerState.NONE
1250
- if (notifyTrackChange && hadTracks) {
1251
- trackChangeListeners.forEach { it(null, -1.0, TrackChangeReason.QUEUE_REPLACED, null) }
1252
- }
1253
1460
  refreshNowPlayingFormatForActiveItem()
1254
1461
  recomputeCapabilities()
1255
1462
  }
@@ -1296,13 +1503,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1296
1503
  if (Looper.myLooper() == Looper.getMainLooper()) {
1297
1504
  return body()
1298
1505
  }
1299
- val latch = CountDownLatch(1)
1506
+ val done = CountDownLatch(1)
1300
1507
  var result: Result<T>? = null
1301
1508
  mainHandler.post {
1302
1509
  result = runCatching { body() }
1303
- latch.countDown()
1510
+ done.countDown()
1304
1511
  }
1305
- latch.await()
1512
+ done.await()
1306
1513
  return result!!.getOrThrow()
1307
1514
  }
1308
1515
 
@@ -1418,6 +1625,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1418
1625
  if (trackIndex < 0 || trackIndex >= engine.allMediaItems.size) return@onMainSync
1419
1626
  currentTrackIndex = trackIndex
1420
1627
  engine.seekTo(trackIndex, maxOf(0L, positionMs))
1628
+ // The index drifted throughout the cast session without touching the
1629
+ // local prefetch or protection windows, so both are stale for the track
1630
+ // local playback is about to resume on.
1631
+ rescheduleLookahead()
1632
+ recomputeCapabilities()
1421
1633
  }
1422
1634
  }
1423
1635
 
@@ -1462,7 +1674,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1462
1674
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1463
1675
  rescheduleLookahead()
1464
1676
  recomputeCapabilities()
1465
- emitQueueChange(QueueChangeReason.SET_QUEUE)
1677
+ emitQueueChange(
1678
+ QueueChangeReason.SET_QUEUE,
1679
+ inserted = tracks.toTypedArray(),
1680
+ insertedAt = if (tracks.isEmpty()) -1 else 0
1681
+ )
1466
1682
  }
1467
1683
 
1468
1684
  /**
@@ -1492,6 +1708,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1492
1708
  // last item must not suppress an identical coded error on the
1493
1709
  // new queue's first item.
1494
1710
  lastErrorKey = null
1711
+ reportedFailureIndices.clear()
1712
+ cancelPendingRetry()
1495
1713
  // Reset auto-retry counters: new queue → new budget per index.
1496
1714
  val attempts = effectiveAutoRetries()
1497
1715
  retryAttemptsRemaining =
@@ -1515,19 +1733,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1515
1733
  */
1516
1734
  @MainThread
1517
1735
  internal fun applyResolvedAssistantQueue(
1518
- newTracks: List<TrackItem>
1736
+ newTracks: List<TrackItem>,
1737
+ append: Boolean = false
1519
1738
  ): List<MediaItem> {
1520
- val items = applyTrackListMetadata(newTracks) ?: return emptyList()
1521
- serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1739
+ // `onAddMediaItems` means Media3 will APPEND what this returns, so the
1740
+ // lib's queue has to grow by the same amount. Replacing it here would
1741
+ // leave the player holding old + new while getQueue() reported only the
1742
+ // new tracks, and every index operation after that maps against the
1743
+ // wrong list. `onSetMediaItems` is a genuine replace and keeps that.
1744
+ // Media3 sets the resolved items on the session player after this returns,
1745
+ // which under crossfade addresses the leading leg only. An armed standby
1746
+ // leg still holds the queue as it stood when it was armed, so promoting it
1747
+ // would drop everything added here and auto-advance would stop at the old
1748
+ // queue end. Settle the fade first; the next boundary re-arms from the
1749
+ // updated queue. `CrossfadeEngine.addMediaItems` does this for the paths
1750
+ // that do go through the engine — this is the path that cannot.
1751
+ serviceBinder?.engine?.settlePendingFade()
1752
+ val resolved = if (append) this.tracks + newTracks else newTracks
1753
+ val appendAt = this.tracks.size
1754
+ val previousIndex = this.currentTrackIndex
1755
+ val items = applyTrackListMetadata(resolved) ?: return emptyList()
1756
+ if (append) {
1757
+ // An append does not move the listener off what is playing.
1758
+ this.currentTrackIndex = previousIndex
1759
+ pendingTrackChangeReason = null
1760
+ }
1761
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(resolved))
1522
1762
  rescheduleLookahead()
1523
1763
  recomputeCapabilities()
1524
- emitQueueChange(QueueChangeReason.SET_QUEUE)
1525
- return items
1764
+ // The tracks came from a voice / Android Auto request, so a subscriber has
1765
+ // no other way to learn them — carry them either way.
1766
+ emitQueueChange(
1767
+ if (append) QueueChangeReason.ADD else QueueChangeReason.SET_QUEUE,
1768
+ inserted = if (append) resolved.drop(appendAt).toTypedArray() else resolved.toTypedArray(),
1769
+ insertedAt = if (append) appendAt else if (resolved.isEmpty()) -1 else 0
1770
+ )
1771
+ // Media3 appends only what it is handed back, so return the added slice.
1772
+ return if (append) items.drop(appendAt) else items
1526
1773
  }
1527
1774
 
1528
1775
  override fun addToQueue(tracks: Array<TrackItem>, insertBefore: Double?): Promise<Unit> =
1529
1776
  Promise.async {
1530
- // Mirror `seekToInternal:890` non-finite guard: NaN/±Infinity
1777
+ // Mirror `seekToInternal`'s non-finite guard: NaN/±Infinity
1531
1778
  // from JS would `.toInt()` to MIN/MAX or 0 silently. When
1532
1779
  // insertBefore is non-finite, drop it (treat as "append at
1533
1780
  // end") rather than silently jumping to position 0.
@@ -1570,6 +1817,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1570
1817
  val ctx = boundContext ?: return -1
1571
1818
 
1572
1819
  val at = QueueMutationArithmetic.clampInsertBefore(insertBefore, tracks.size)
1820
+ // A mutation renumbers positions, so every recorded index but the current
1821
+ // track's is stale afterwards. The one that matters is the stop on the
1822
+ // track being heard: it follows the index through the mutation, and the
1823
+ // rest go rather than being carried onto whatever now sits at their number.
1824
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1573
1825
  this.tracks = tracks.toMutableList().apply { addAll(at, newTracks) }
1574
1826
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
1575
1827
  currentIndex = currentTrackIndex,
@@ -1578,10 +1830,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1578
1830
  )
1579
1831
 
1580
1832
  // Index-keyed retry-budget map: insertion at `at` shifts every
1581
- // existing key >= at up by `newTracks.size`. Snapshot via
1582
- // `filterKeys` so mutation of the live map is safe regardless of
1583
- // iteration order; `toSortedMap` is incidental cleanliness, not
1584
- // load-bearing for correctness.
1833
+ // existing key >= at up by `newTracks.size`. The shift must run in
1834
+ // descending key order: each iteration removes `oldIdx` and writes
1835
+ // `oldIdx + shift`, and in ascending order the write for key k lands
1836
+ // on k + shift, and the later iteration for k + shift removes it, so
1837
+ // that budget is lost.
1838
+ cancelPendingRetry()
1585
1839
  val attempts = effectiveAutoRetries()
1586
1840
  val shift = newTracks.size
1587
1841
  val shifted = retryAttemptsRemaining
@@ -1594,6 +1848,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1594
1848
  for (newIdx in at until at + shift) {
1595
1849
  retryAttemptsRemaining[newIdx] = attempts
1596
1850
  }
1851
+ reportedFailureIndices.clear()
1852
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1597
1853
  val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1598
1854
  serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
1599
1855
  // Diverges from iOS: add-into-empty-queue on iOS leaves the
@@ -1614,7 +1870,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1614
1870
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1615
1871
  rescheduleLookahead()
1616
1872
  recomputeCapabilities()
1617
- emitQueueChange(QueueChangeReason.ADD)
1873
+ emitQueueChange(
1874
+ QueueChangeReason.ADD,
1875
+ inserted = newTracks.toTypedArray(),
1876
+ insertedAt = at
1877
+ )
1618
1878
  return at
1619
1879
  }
1620
1880
 
@@ -1660,6 +1920,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1660
1920
  newTracks.removeAt(i)
1661
1921
  }
1662
1922
  this.tracks = newTracks
1923
+ // A mutation renumbers positions, so every recorded index but the current
1924
+ // track's is stale afterwards. The one that matters is the stop on the
1925
+ // track being heard: it follows the index through the mutation, and the
1926
+ // rest go rather than being carried onto whatever now sits at their number.
1927
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1663
1928
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
1664
1929
  currentIndex = oldCur,
1665
1930
  sanitisedIndices = sanitised,
@@ -1698,9 +1963,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1698
1963
  // - **Why not preserve via remap**: requires walking the map
1699
1964
  // AND the sanitised-indices in lockstep with offset
1700
1965
  // accounting. The clear-and-repopulate is intentional.
1966
+ cancelPendingRetry()
1701
1967
  val attempts = effectiveAutoRetries()
1702
1968
  retryAttemptsRemaining.clear()
1703
1969
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1970
+ reportedFailureIndices.clear()
1971
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1704
1972
  // Mirror the wake-mode hook on `setQueueInternal` /
1705
1973
  // `addToQueueInternal`: removing the last remote URL out of a
1706
1974
  // mixed queue must downgrade to LOCAL so the WiFi wake lock
@@ -1710,7 +1978,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1710
1978
  recomputeCapabilities()
1711
1979
  // Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
1712
1980
  // removed; tracks + index are final.
1713
- emitQueueChange(QueueChangeReason.REMOVE)
1981
+ emitQueueChange(QueueChangeReason.REMOVE, removed = sanitised)
1714
1982
  return sanitised
1715
1983
  }
1716
1984
 
@@ -1721,7 +1989,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1721
1989
  // mirror the reorder to the receiver. `routeMoveInQueue` is
1722
1990
  // suspending so it runs outside `onMain`. Mirrors iOS `moveInQueue`.
1723
1991
  val move = onMain {
1724
- // Mirror `seekToInternal:890` non-finite guard.
1992
+ // Mirror `seekToInternal`'s non-finite guard.
1725
1993
  if (!fromIndex.isFinite() || !toIndex.isFinite()) return@onMain null
1726
1994
  val from = fromIndex.toInt()
1727
1995
  val to = toIndex.toInt()
@@ -1758,6 +2026,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1758
2026
  mutable.add(to, moved)
1759
2027
  this.tracks = mutable
1760
2028
 
2029
+ // A mutation renumbers positions, so every recorded index but the current
2030
+ // track's is stale afterwards. The one that matters is the stop on the
2031
+ // track being heard: it follows the index through the mutation, and the
2032
+ // rest go rather than being carried onto whatever now sits at their number.
2033
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1761
2034
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
1762
2035
  currentIndex = currentTrackIndex,
1763
2036
  fromIndex = from,
@@ -1771,19 +2044,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1771
2044
  // arbitrarily. Cheapest correct path is to clear + repopulate
1772
2045
  // (same trade-off as removeFromQueueInternal — structural reset
1773
2046
  // semantics for queue mutations).
2047
+ cancelPendingRetry()
1774
2048
  val attempts = effectiveAutoRetries()
1775
2049
  retryAttemptsRemaining.clear()
1776
2050
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
2051
+ reportedFailureIndices.clear()
2052
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1777
2053
  rescheduleLookahead()
1778
2054
  recomputeCapabilities()
1779
2055
  // Reached only past the invalid / `from == to` / pinned-current
1780
2056
  // guards, so the order actually changed.
1781
- emitQueueChange(QueueChangeReason.MOVE)
2057
+ emitQueueChange(QueueChangeReason.MOVE, movedFrom = from, movedTo = to)
1782
2058
  return true
1783
2059
  }
1784
2060
 
1785
- override fun getQueue(): Array<TrackItem> =
1786
- tracks.toTypedArray()
2061
+ override fun getQueue(): Promise<Array<TrackItem>> =
2062
+ Promise.async { onMain { tracks.toTypedArray() } }
1787
2063
 
1788
2064
  override fun clearQueue(): Promise<Unit> =
1789
2065
  Promise.async {
@@ -1812,6 +2088,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1812
2088
  // Reset error-dedup so a subsequent queue's identical-coded error
1813
2089
  // isn't suppressed by a stale entry from the cleared queue.
1814
2090
  lastErrorKey = null
2091
+ reportedFailureIndices.clear()
2092
+ cancelPendingRetry()
1815
2093
  retryAttemptsRemaining.clear()
1816
2094
  tracks = emptyList()
1817
2095
  currentTrackIndex = -1
@@ -1866,18 +2144,47 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1866
2144
  // engine. Same shape at every transport entry point — see
1867
2145
  // `CastTransportRouter` for the early-return contract.
1868
2146
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1869
- // Stuck-recovery: if the user re-taps play after
1870
- // an exhausted-retries error, give one more shot. Reset
1871
- // counter to 1 (single shot, not full autoRetries — each
1872
- // user tap = one attempt for predictable UX) and re-prepare.
1873
- // Mirrors iOS `play()` stuck-recovery.
1874
- if (lastReportedState == PlayerState.ERROR &&
1875
- currentTrackIndex >= 0 &&
1876
- (retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
1877
- retryAttemptsRemaining[currentTrackIndex] = 1
2147
+ // Stuck-recovery: if the user re-taps play after an exhausted-retries
2148
+ // error, hand the track back the allowance the consumer configured and
2149
+ // re-prepare. Not a hard-coded 1: a consumer who set `autoRetries: 0`
2150
+ // asked for none. Mirrors iOS `play()` stuck-recovery.
2151
+ if (currentTrackIndex >= 0 && currentTrackIndex in reportedFailureIndices) {
2152
+ retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
1878
2153
  lastErrorKey = null
1879
- player?.prepare()
2154
+ // `retryFailedItem`, not a bare `prepare()`: a bare prepare re-uses
2155
+ // the wedged DataSource this exists to discard. It removes and re-adds
2156
+ // the item, which rebuilds the source from the MediaSource.Factory.
2157
+ retryFailedItem(currentTrackIndex)
2158
+ } else if (currentTrackIndex >= 0 &&
2159
+ player?.playbackState == Player.STATE_IDLE &&
2160
+ (player?.mediaItemCount ?: 0) > 0
2161
+ ) {
2162
+ // With no current track there is no position to re-seat, which the
2163
+ // index guard above covers; the branch below refuses to start
2164
+ // anything in that state at all.
2165
+ // A failure leaves ExoPlayer in STATE_IDLE, and a skip off the failed
2166
+ // track seeks without re-preparing — so `play()` would set
2167
+ // `playWhenReady` on a player that never leaves IDLE and nothing would
2168
+ // be heard. `retryFailedItem`, not a bare `prepare()`: on a same-index
2169
+ // skip the item is the one that failed, and a bare prepare re-uses the
2170
+ // DataSource that wedged. The timeline has to hold items for prepare to
2171
+ // reach anything — between `clearQueue` and the next `setMediaItems`
2172
+ // it is empty, and preparing there lands in STATE_ENDED, which the
2173
+ // line below reads as a finished queue.
2174
+ retryFailedItem(currentTrackIndex)
1880
2175
  }
2176
+ // At the end of the queue there is nothing to play: ExoPlayer stays
2177
+ // STATE_ENDED through play(), and a playWhenReady left set would
2178
+ // start the next track a skip seats without anyone asking. A skip, a
2179
+ // seek or a fresh queue leaves this state; play() alone restarts
2180
+ // nothing — the same contract as iOS.
2181
+ if (player?.playbackState == Player.STATE_ENDED && tracks.isNotEmpty()) return@onMain
2182
+ // Nothing is seated: an add into an empty queue primes the player and
2183
+ // deliberately leaves the current index at -1, so starting here would
2184
+ // play position 0 while every read still answers for no track. The
2185
+ // consumer seats one — `skipToIndex(0)` — and plays. iOS reaches the
2186
+ // same outcome by leaving its queue player empty until then.
2187
+ if (currentTrackIndex < 0) return@onMain
1881
2188
  // Stash USER on the pending-reason channel so the
1882
2189
  // subsequent Player.Listener fire attributes the transition
1883
2190
  // correctly.
@@ -1894,17 +2201,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1894
2201
  // During cast the receiver owns playback — forward a play so a failed
1895
2202
  // receiver item re-attempts.
1896
2203
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1897
- // Local recovery: only meaningful for a track that errored after its
1898
- // automatic retries were exhausted. Give it one fresh attempt reset
1899
- // the per-track retry budget, then rebuild the failed item via
1900
- // retryFailedItem, which discards the wedged DataSource (a bare
1901
- // prepare() would re-use it), restores the pre-failure position, and
1902
- // resumes playing.
1903
- if (lastReportedState != PlayerState.ERROR || currentTrackIndex < 0) return@onMain
1904
- retryAttemptsRemaining[currentTrackIndex] = 1
2204
+ // A user asking for a retry has a reason, and the library is not the
2205
+ // one to judge it. This is the manual attempt once the automatic one
2206
+ // is spent, so gating it on the player's state would gate recovery on
2207
+ // the state of a player that has already failed to recover — and a
2208
+ // wedged decoder still reporting PLAYING is exactly when someone
2209
+ // reaches for the button. retryFailedItem below discards the wedged
2210
+ // DataSource (a bare prepare() would re-use it), restores the
2211
+ // pre-failure position, and resumes playing.
2212
+ //
2213
+ // Only the bounds remain: no current track is genuinely nothing to
2214
+ // retry, which is not second-guessing the request.
2215
+ if (currentTrackIndex < 0) return@onMain
2216
+ // The configured budget, not a hard-coded 1: this restores the track's
2217
+ // automatic allowance for whatever fails *next*, and a consumer who
2218
+ // set `autoRetries: 0` asked for none.
2219
+ retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
1905
2220
  lastErrorKey = null
1906
2221
  pendingStateChangeReason = StateChangeReason.USER
1907
2222
  wantsToPlay = true
2223
+ // Drops this track's report on its way through, before its own
2224
+ // current-track bail, so a retry always clears what it attempts.
1908
2225
  retryFailedItem(currentTrackIndex)
1909
2226
  recomputeBufferState()
1910
2227
  }
@@ -2108,7 +2425,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2108
2425
  override fun skipToIndex(index: Double): Promise<Unit> =
2109
2426
  Promise.async {
2110
2427
  onMain {
2111
- // Mirror `seekToInternal:890` non-finite guard. Without this
2428
+ // Mirror `seekToInternal`'s non-finite guard. Without this
2112
2429
  // `Double.NaN.toInt()` returns 0 — `skipToIndex(NaN)` would
2113
2430
  // silently jump to track 0 instead of no-op'ing. iOS uses
2114
2431
  // `InputGuards.validQueueIndex` for the same guarantee.
@@ -2139,6 +2456,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2139
2456
  val p = player ?: return
2140
2457
  if (target < 0 || target >= tracks.size) return
2141
2458
  currentTrackIndex = target
2459
+ // Seating a position is an attempt at it, so the report against it no
2460
+ // longer describes what is about to play. Cleared here rather than left to
2461
+ // the media-item transition, which does not fire for a skip that seats the
2462
+ // track already selected. Mirrors iOS `applyNewCurrentIndex`.
2463
+ reportedFailureIndices.remove(target)
2142
2464
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_TO_INDEX
2143
2465
  // Guard against IllegalSeekPositionException: `seekTo(index, 0L)`
2144
2466
  // throws when `index` is outside `mediaItemCount`. When the lib
@@ -2166,6 +2488,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2166
2488
  val p = player ?: return
2167
2489
  val next = computeNextIndex() ?: return
2168
2490
  currentTrackIndex = next
2491
+ reportedFailureIndices.remove(next)
2169
2492
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_NEXT
2170
2493
  if (next < p.mediaItemCount) {
2171
2494
  serviceBinder?.engine?.seekTo(next, 0L)
@@ -2204,6 +2527,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2204
2527
  is QueueSkipArithmetic.SkipToPreviousAction.Previous -> {
2205
2528
  val prev = action.index
2206
2529
  currentTrackIndex = prev
2530
+ reportedFailureIndices.remove(prev)
2207
2531
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
2208
2532
  if (prev < p.mediaItemCount) {
2209
2533
  serviceBinder?.engine?.seekTo(prev, 0L)
@@ -2218,6 +2542,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2218
2542
  // untouched (stashing a reason no transition consumes would leak
2219
2543
  // into the next real skip).
2220
2544
  if (currentTrackIndex in 0 until p.mediaItemCount) {
2545
+ reportedFailureIndices.remove(currentTrackIndex)
2221
2546
  serviceBinder?.engine?.seekTo(currentTrackIndex, 0L)
2222
2547
  }
2223
2548
  }
@@ -2240,14 +2565,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2240
2565
  repeatMode = repeatModeAsSkipMode()
2241
2566
  )
2242
2567
 
2243
- @VisibleForTesting
2244
- internal fun computePreviousIndex(): Int? =
2245
- QueueSkipArithmetic.computePrevious(
2246
- trackCount = tracks.size,
2247
- currentIndex = currentTrackIndex,
2248
- repeatMode = repeatModeAsSkipMode()
2249
- )
2250
-
2251
2568
  private fun repeatModeAsSkipMode(): QueueSkipRepeatMode = when (repeatModeState) {
2252
2569
  RepeatMode.OFF -> QueueSkipRepeatMode.OFF
2253
2570
  RepeatMode.TRACK -> QueueSkipRepeatMode.TRACK
@@ -2279,7 +2596,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2279
2596
  * clearQueueInternal, skipToIndexInternal / skipToNextInternal /
2280
2597
  * skipToPreviousInternal (all index writes), setRepeatModeInternal,
2281
2598
  * shuffleQueueInternal, handleMediaItemTransition (covers Media3
2282
- * REASON_AUTO + REASON_REPEAT auto-advance), destroyInternal.
2599
+ * REASON_AUTO + REASON_REPEAT auto-advance), the crossfade
2600
+ * fade-start / cancel index writes, emitRemoteTrackChange and
2601
+ * resumeLocalAfterCast (cast), destroyInternal.
2283
2602
  */
2284
2603
  @MainThread
2285
2604
  internal fun recomputeCapabilities() {
@@ -2470,6 +2789,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2470
2789
  newEngine.setVolume(volumeState)
2471
2790
  newEngine.setPlaybackSpeed(playbackSpeedState)
2472
2791
  newEngine.setPitchCorrectionMode(pitchCorrectionModeState)
2792
+ installSuppliedReplayGainResolver(newEngine)
2473
2793
  // Drive the AirPlay receiver-display metadata sync. When the new engine is
2474
2794
  // the dedicated AirPlay engine, hand the now-active session this engine's
2475
2795
  // ExoPlayer — registered in AirPlayEngine.init, so CastSinkRouter.player()
@@ -2489,7 +2809,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2489
2809
  override fun setPlaybackSpeed(rate: Double): Promise<Unit> =
2490
2810
  Promise.async {
2491
2811
  onMain {
2492
- // Mirror `seekToInternal:890` non-finite guard. NaN →
2812
+ // Mirror `seekToInternal`'s non-finite guard. NaN →
2493
2813
  // undefined ExoPlayer audio behaviour at the AudioSink;
2494
2814
  // ±Infinity → IllegalArgumentException from
2495
2815
  // `PlaybackParameters` constructor.
@@ -2537,7 +2857,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2537
2857
  override fun setVolume(volume: Double): Promise<Unit> =
2538
2858
  Promise.async {
2539
2859
  onMain {
2540
- // Mirror `seekToInternal:890` non-finite guard. NaN →
2860
+ // Mirror `seekToInternal`'s non-finite guard. NaN →
2541
2861
  // undefined audio output; ±Infinity → ditto.
2542
2862
  if (!volume.isFinite()) return@onMain
2543
2863
  if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetVolume(volume.toFloat())) return@onMain
@@ -2840,16 +3160,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2840
3160
  // resume after the rebuild.
2841
3161
  val wasPlaying = p.isPlaying
2842
3162
 
2843
- // Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
2844
- val newTracks = tracks.shuffled()
3163
+ // Shuffle positions rather than tracks: the permutation is what
3164
+ // onQueueChange reports, and it cannot be recovered from the reordered
3165
+ // list afterwards. Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
3166
+ val order = tracks.indices.shuffled()
3167
+ val newTracks = order.map { tracks[it] }
2845
3168
 
2846
3169
  this.tracks = newTracks
2847
3170
  this.currentTrackIndex = 0
2848
3171
  pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
2849
3172
  // Destructive shuffle replaces every position; reset retry budget.
3173
+ cancelPendingRetry()
2850
3174
  val attempts = effectiveAutoRetries()
2851
3175
  retryAttemptsRemaining.clear()
2852
3176
  for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
3177
+ // The permutation decides what lands at position 0, so the track the queue
3178
+ // stopped on is not the one the index now names. The stop is dropped with
3179
+ // the retry budget rather than carried onto whichever track was moved to
3180
+ // the front.
3181
+ reportedFailureIndices.clear()
2853
3182
 
2854
3183
  val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
2855
3184
  serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
@@ -2864,7 +3193,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2864
3193
  rescheduleLookahead()
2865
3194
  recomputeCapabilities()
2866
3195
  // Skip a single-track shuffle — the order can't change.
2867
- if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
3196
+ if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE, order = order)
2868
3197
 
2869
3198
  return ShuffleResult(
2870
3199
  tracks = newTracks.toTypedArray(),
@@ -2920,11 +3249,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2920
3249
  )
2921
3250
  }
2922
3251
 
2923
- // No `syncMain` hop on the next 3 getters: each reads a single
3252
+ // No `syncMain` hop on the next five getters: each reads a single
2924
3253
  // `@Volatile` field that is only written on main, so the JVM
2925
3254
  // memory model gives the cross-thread happens-before edge for
2926
- // free. iOS uses `onMainSync` because Swift has no `@Volatile`-
2927
- // equivalent storage class.
3255
+ // free. iOS hops through `onPlayerQueueSync` instead: its state is
3256
+ // confined to `playerQueue`, and Swift has no `@Volatile`-equivalent
3257
+ // storage class.
2928
3258
  override fun getCurrentTrackIndex(): Double = currentTrackIndex.toDouble()
2929
3259
 
2930
3260
  override fun getCurrentTrackSource(): TrackSource? = currentTrackSource
@@ -2968,26 +3298,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2968
3298
  }
2969
3299
 
2970
3300
  override fun onQueueChange(
2971
- callback: (queue: Array<TrackItem>, currentIndex: Double, reason: QueueChangeReason) -> Unit
3301
+ callback: (delta: QueueChangeDelta, currentIndex: Double, reason: QueueChangeReason) -> Unit
2972
3302
  ): () -> Unit {
2973
3303
  val id = queueChangeListeners.add(callback)
2974
3304
  return { queueChangeListeners.remove(id) }
2975
3305
  }
2976
3306
 
2977
3307
  /**
2978
- * Fire onQueueChange with an atomic snapshot of the post-mutation
2979
- * queue + current index. Main-thread only every queue mutation
2980
- * commits on main, so [tracks] / [currentTrackIndex] are settled here.
2981
- * Callers gate this on an actual content/order change so a no-op
2982
- * mutation never emits.
3308
+ * Emit a queue change as a description of what happened rather than a copy of
3309
+ * the queue. Callers pass only the fields their mutation defines; the rest
3310
+ * stay empty, and `revision` lets a subscriber notice a dropped event.
3311
+ * Main-thread only every queue mutation commits on main, so [tracks] /
3312
+ * [currentTrackIndex] are settled here. Callers gate this on an actual
3313
+ * content/order change so a no-op mutation never emits.
2983
3314
  */
2984
3315
  @MainThread
2985
- private fun emitQueueChange(reason: QueueChangeReason) {
2986
- val snapshot = tracks.toTypedArray()
3316
+ private fun emitQueueChange(
3317
+ reason: QueueChangeReason,
3318
+ inserted: Array<TrackItem> = emptyArray(),
3319
+ insertedAt: Int = -1,
3320
+ removed: List<Int> = emptyList(),
3321
+ movedFrom: Int = -1,
3322
+ movedTo: Int = -1,
3323
+ order: List<Int> = emptyList()
3324
+ ) {
3325
+ queueRevision += 1
3326
+ val delta = QueueChangeDelta(
3327
+ revision = queueRevision.toDouble(),
3328
+ length = tracks.size.toDouble(),
3329
+ inserted = inserted,
3330
+ insertedAt = insertedAt.toDouble(),
3331
+ removed = removed.map { it.toDouble() }.toDoubleArray(),
3332
+ movedFrom = movedFrom.toDouble(),
3333
+ movedTo = movedTo.toDouble(),
3334
+ order = order.map { it.toDouble() }.toDoubleArray()
3335
+ )
2987
3336
  val index = currentTrackIndex.toDouble()
2988
- queueChangeListeners.forEach { it(snapshot, index, reason) }
3337
+ queueChangeListeners.forEach { it(delta, index, reason) }
2989
3338
  }
2990
3339
 
3340
+ /** Increments once per queue-change emit; a gap tells a subscriber it missed one. */
3341
+ private var queueRevision: Long = 0
3342
+
2991
3343
  override fun onProgress(
2992
3344
  callback: (progress: PlayerProgress) -> Unit
2993
3345
  ): () -> Unit {
@@ -3149,15 +3501,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3149
3501
  * drained). Post-bind: registers immediately. Either way the
3150
3502
  * returned disposer matches the consumer's expectation that
3151
3503
  * calling it tears the registration down regardless of timing.
3152
- *
3153
- * Past failure: `bootstrap.ts` (demo) calls `registerPlaybackService`
3154
- * at bundle-load time, which fires every `onCarConnect` /
3155
- * `onPlayFromIdRequest` / etc. BEFORE `configure()` has bound the
3156
- * service. The old "?: NO_OP_DISPOSER" fallback silently dropped
3157
- * every registration; the carService thought it had wired up but
3158
- * the lib had nothing recorded, so Android Auto's later bind hit
3159
- * `dispatchCarConnect` with a null callback + timed out → AA
3160
- * "No items".
3504
+ * `registerPlaybackService` may run before `configure()` resolves, and
3505
+ * [serviceBinder] is null again between an OS service kill and the
3506
+ * rebind (`onServiceDisconnected`); both cases queue here and are
3507
+ * applied by [drainPendingBrowseRegistrations] on the next
3508
+ * `onServiceConnected`.
3161
3509
  */
3162
3510
  private fun deferredRegister(
3163
3511
  register: (BrowseCallbackRegistry) -> () -> Unit
@@ -3203,14 +3551,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3203
3551
  }
3204
3552
  }
3205
3553
 
3206
- override fun getLookaheadCacheStatus(): CacheStatus =
3207
- syncMain { buildCacheStatusSnapshot() }
3554
+ /**
3555
+ * Asynchronous because the snapshot counts fully-cached entries, which walks
3556
+ * every cache key and takes the cache's monitor per key — contended against
3557
+ * the prefetch thread. Resolved off both the JS thread and the app looper;
3558
+ * every field it reads is published for that.
3559
+ */
3560
+ override fun getLookaheadCacheStatus(): Promise<CacheStatus> =
3561
+ Promise.async { buildCacheStatusSnapshot() }
3208
3562
 
3563
+ /**
3564
+ * Wipe the cache and resolve once it is actually gone.
3565
+ *
3566
+ * The delete is file IO, so it runs off the app looper; the promise resolves
3567
+ * after it, because a consumer that awaits this and then reads
3568
+ * `getLookaheadCacheStatus()` must not see the pre-wipe size.
3569
+ */
3209
3570
  override fun clearLookaheadCache(): Promise<Unit> =
3210
- Promise.async { onMain { clearLookaheadCacheInternal() } }
3571
+ Promise.async {
3572
+ onMain { clearLookaheadCacheInternal() }?.join()
3573
+ onMain { emitCacheStatus() }
3574
+ }
3211
3575
 
3212
3576
  override fun onCacheStatusChange(callback: (status: CacheStatus) -> Unit): () -> Unit {
3213
3577
  val id = cacheStatusListeners.add(callback)
3578
+ // Deliver the current status once. Otherwise a settings screen shows its
3579
+ // placeholder until the next prefetch completes — which never happens for a
3580
+ // fully-cached queue or a consumer running with prefetch off.
3581
+ cacheIoScope.launch(cacheStatusDispatcher) {
3582
+ val snapshot = buildCacheStatusSnapshot()
3583
+ mainHandler.post {
3584
+ if (cacheStatusListeners.contains(id)) callback(snapshot)
3585
+ }
3586
+ }
3214
3587
  return { cacheStatusListeners.remove(id) }
3215
3588
  }
3216
3589
 
@@ -3267,6 +3640,30 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3267
3640
 
3268
3641
  override fun getReplayGainMode(): ReplayGainMode = ReplayGainConfig.getMode()
3269
3642
 
3643
+ /**
3644
+ * Hand a freshly-installed engine the reader for consumer-supplied
3645
+ * ReplayGain. The engine holds no queue metadata of its own — it
3646
+ * plays `MediaItem`s — so the lookup from a queue index back to the
3647
+ * consumer's `TrackItem` lives here, where the canonical [tracks]
3648
+ * list does.
3649
+ *
3650
+ * The lambda reads [tracks] on every invocation rather than closing
3651
+ * over a snapshot, so a `setQueue` carrying corrected values is
3652
+ * picked up by the next recompute. Called wherever an engine is
3653
+ * installed — the configure-time rebuild and the playback-mode swap —
3654
+ * because a fresh engine starts with no resolver.
3655
+ */
3656
+ private fun installSuppliedReplayGainResolver(engine: PlaybackEngine?) {
3657
+ val resolver: (Int) -> SuppliedReplayGain? = { index ->
3658
+ tracks.getOrNull(index)?.let { SuppliedReplayGain.from(it) }
3659
+ }
3660
+ when (engine) {
3661
+ is GaplessEngine -> engine.suppliedReplayGainResolver = resolver
3662
+ is CrossfadeEngine -> engine.suppliedReplayGainResolver = resolver
3663
+ else -> Unit
3664
+ }
3665
+ }
3666
+
3270
3667
  /**
3271
3668
  * Re-resolve the active item's format and emit if it differs from
3272
3669
  * the last cached value. Call sites: [Player.Listener.onMediaItemTransition]
@@ -3326,14 +3723,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3326
3723
  * live — no configure() cycle needed:
3327
3724
  *
3328
3725
  * - `enabled` toggle:
3329
- * - true → false: tears down the WRITER (no more proactive
3330
- * prefetch). The CACHE stays alive Media3's CacheDataSource
3331
- * in the player's MediaSource.Factory still reads existing
3332
- * entries, so disabling can't yank cache out from under
3333
- * in-flight reads.
3726
+ * - true → false: tears down the WRITER (no more proactive prefetch)
3727
+ * and routes the player's reads to the origin from the next item
3728
+ * load; the item already loading keeps the source it opened. The
3729
+ * cache OBJECT stays alive (Media3 allows one per directory) but is
3730
+ * neither read nor written while disabled.
3334
3731
  * - false → true: rebuilds the writer against the existing cache
3335
- * (the cache is built at configure() regardless of `enabled`),
3336
- * so caching turns on without a configure() cycle.
3732
+ * (the cache is built at configure() regardless of `enabled`) and
3733
+ * routes reads through it again, so caching turns on without a
3734
+ * configure() cycle.
3337
3735
  *
3338
3736
  * - `lookaheadCount` change: live update via
3339
3737
  * `LookaheadCacheWriter.defaultLookaheadCount`. Applies on the
@@ -3355,8 +3753,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3355
3753
 
3356
3754
  val countChanged = newConfig.lookaheadCount != previous.lookaheadCount
3357
3755
  val enableChanged = newConfig.enabled != previous.enabled
3358
-
3359
- // Disable tear down WRITER only. Cache stays for player reads.
3756
+ val cellularChanged =
3757
+ (newConfig.allowsCellularAccess ?: true) != (previous.allowsCellularAccess ?: true)
3758
+
3759
+ // The player's reads follow the flag from the next item load: disabled
3760
+ // means the origin, whatever the disk holds. The cache object stays —
3761
+ // Media3 allows one instance per directory — but nothing reads or writes
3762
+ // it until it is enabled again.
3763
+ if (enableChanged) {
3764
+ lookaheadCache?.servesPlayback = newConfig.enabled
3765
+ }
3766
+ // Disable → tear down the writer.
3360
3767
  if (enableChanged && !newConfig.enabled) {
3361
3768
  lookaheadCacheWriter?.release()
3362
3769
  lookaheadCacheWriter = null
@@ -3377,51 +3784,80 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3377
3784
  networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
3378
3785
  config.networkTimeoutMs
3379
3786
  )
3380
- )
3787
+ ),
3788
+ isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
3381
3789
  ).apply {
3382
3790
  defaultLookaheadCount =
3383
3791
  newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3792
+ allowsCellularAccess = newConfig.allowsCellularAccess ?: true
3384
3793
  onTrackProcessed = { onMainPostStatusEmit() }
3385
3794
  }
3386
3795
  rescheduleLookahead()
3387
3796
  }
3388
3797
  }
3389
3798
 
3390
- // Live count update — safe regardless of player lifecycle. Skipped when
3391
- // the enable branch above just rebuilt the writer (it already applied the
3392
- // new count + rescheduled), so a single enable+count change reschedules once.
3799
+ // Live policy update — safe regardless of player lifecycle. `lookaheadCount`
3800
+ // resizes the window; `allowsCellularAccess` decides whether the window is
3801
+ // fetched at all, so turning it off empties it and cancels the prefetch in
3802
+ // flight and turning it back on refills it. Both take effect on the single
3803
+ // reschedule below. Skipped when the enable branch above just rebuilt the
3804
+ // writer, which already applied both values and rescheduled.
3393
3805
  val writer = lookaheadCacheWriter
3394
- if (countChanged && !enableChanged && writer != null) {
3395
- writer.defaultLookaheadCount =
3396
- newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3806
+ if (!enableChanged && writer != null && (countChanged || cellularChanged)) {
3807
+ if (countChanged) {
3808
+ writer.defaultLookaheadCount =
3809
+ newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3810
+ }
3811
+ if (cellularChanged) {
3812
+ writer.allowsCellularAccess = newConfig.allowsCellularAccess ?: true
3813
+ }
3397
3814
  rescheduleLookahead()
3398
3815
  }
3399
3816
 
3817
+ // `lookaheadCount` sizes the protected window, so a change to it must move
3818
+ // the window even when the branches above did not reschedule — prefetch
3819
+ // disabled leaves no writer, and a combined disable+count change skips the
3820
+ // count branch entirely. The cache still evicts in both cases.
3821
+ refreshCacheProtection()
3400
3822
  emitCacheStatus()
3401
3823
  }
3402
3824
 
3825
+ /**
3826
+ * Cancel the prefetch and wipe the cache, returning the job doing the file
3827
+ * work so a caller can await it. Null when there is no cache to clear.
3828
+ *
3829
+ * The wipe is a per-resource delete walk, so it runs off the app looper.
3830
+ * Cancelling the prefetch first is what keeps it from racing: a `CacheWriter`
3831
+ * mid-write into a key just removed re-creates the entry seconds later.
3832
+ *
3833
+ * Deliberately does NOT call [rescheduleLookahead] — an immediate refill makes
3834
+ * `clearLookaheadCache` impossible to observe at zero from JS, and "clear"
3835
+ * means "wipe and stop" (the consumer re-triggers prefetch via
3836
+ * setLookaheadCache, skipToIndex, or setQueue). Matches iOS.
3837
+ */
3838
+ @MainThread
3403
3839
  @VisibleForTesting
3404
- internal fun clearLookaheadCacheInternal() {
3405
- val cache = lookaheadCache ?: return
3406
- // Cancel any in-flight prefetch first so we don't race with the
3407
- // wipe (CacheWriter mid-write into a key we just removed would
3408
- // re-create the entry seconds later). We deliberately do NOT
3409
- // call rescheduleLookahead() here — an immediate refill makes
3410
- // `clearLookaheadCache` impossible to observe at zero from JS,
3411
- // and "clear" semantically means "wipe and stop" (consumer can
3412
- // re-trigger prefetch via setLookaheadCache, skipToIndex, or
3413
- // setQueue). Matches iOS.
3840
+ internal fun clearLookaheadCacheInternal(): Job? {
3841
+ val cache = lookaheadCache ?: return null
3842
+ // Stops the blocking CacheWriter too, not just the coroutine. Its cancelled
3843
+ // flag is polled between chunk reads, so a span already mid-write can still
3844
+ // land after the wipe; this closes the window rather than eliminating it.
3414
3845
  lookaheadCacheWriter?.cancel()
3415
- cache.clear()
3416
- emitCacheStatus()
3846
+ // File work only. Emitting from in here would make the job depend on the
3847
+ // main thread, which deadlocks anyone awaiting it with a blocking wait on
3848
+ // that thread. The caller emits instead.
3849
+ return cacheIoScope.launch { cache.clear() }
3417
3850
  }
3418
3851
 
3419
3852
  /**
3420
3853
  * Build a [CacheStatus] snapshot from the current cache + writer
3421
- * state. Synchronouscalled inside `syncMain` from
3422
- * [getLookaheadCacheStatus].
3854
+ * state. Called off the app looper from the `Promise.async` pool
3855
+ * (`getLookaheadCacheStatus`) and the serialised cache-status IO
3856
+ * dispatcher (`emitCacheStatus` / `onCacheStatusChange`); reads
3857
+ * `lookaheadCache` / `lookaheadCacheWriter` as @Volatile snapshots.
3423
3858
  */
3424
- private fun buildCacheStatusSnapshot(): CacheStatus {
3859
+ @VisibleForTesting
3860
+ internal fun buildCacheStatusSnapshot(): CacheStatus {
3425
3861
  val cache = lookaheadCache
3426
3862
  val writer = lookaheadCacheWriter
3427
3863
  val currentSizeBytes = cache?.currentSizeBytes() ?: 0L
@@ -3444,21 +3880,32 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3444
3880
  )
3445
3881
  }
3446
3882
 
3447
- /** Fire the cache-status listeners with a fresh snapshot. Called on main. */
3883
+ /**
3884
+ * Fire the cache-status listeners with a fresh snapshot. Called from main
3885
+ * and from the writer's IO thread (`onTrackProcessed`); the registry is
3886
+ * copy-on-write and the listeners run on main either way.
3887
+ */
3448
3888
  private fun emitCacheStatus() {
3449
3889
  if (cacheStatusListeners.isEmpty) return
3450
- val snapshot = buildCacheStatusSnapshot()
3451
- cacheStatusListeners.forEach { it(snapshot) }
3890
+ // Built off the app looper. The snapshot counts fully-cached entries,
3891
+ // which walks every cache key and takes the cache's monitor per key —
3892
+ // contended against the prefetch thread that is committing downloads —
3893
+ // and this fires after every prefetch iteration whenever a consumer is
3894
+ // subscribed. Listeners are still called on the looper they expect.
3895
+ cacheIoScope.launch(cacheStatusDispatcher) {
3896
+ val snapshot = buildCacheStatusSnapshot()
3897
+ mainHandler.post { cacheStatusListeners.forEach { it(snapshot) } }
3898
+ }
3452
3899
  }
3453
3900
 
3454
3901
  /**
3455
- * Hop to main + emit a cache-status snapshot. Bound to the writer's
3456
- * `onTrackProcessed` hook so post-download status updates are
3457
- * always dispatched on main even though the writer's coroutine
3458
- * runs on Dispatchers.IO.
3902
+ * Emit a cache-status snapshot. Bound to the writer's `onTrackProcessed`
3903
+ * hook, so it fires once per prefetched track.
3459
3904
  */
3460
3905
  private fun onMainPostStatusEmit() {
3461
- mainHandler.post { emitCacheStatus() }
3906
+ // `emitCacheStatus` does its own hopping — off the looper to build, back
3907
+ // onto it to deliver — so there is nothing to post first.
3908
+ emitCacheStatus()
3462
3909
  }
3463
3910
 
3464
3911
  // --- Events — observer wiring + dispatch
@@ -3489,12 +3936,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3489
3936
  }
3490
3937
 
3491
3938
  private fun tearDownEventObservers() {
3939
+ // A retry posted before teardown would otherwise land on a released engine.
3940
+ // Cancelled here rather than in stopProgressTick, which also runs when the
3941
+ // last progress subscriber unsubscribes — a screen unmounting must not kill
3942
+ // an in-flight retry.
3943
+ cancelPendingRetry()
3492
3944
  // Clear the delegate from the engine TrackPlayer last wired
3493
3945
  // itself onto, even if [serviceBinder] has been re-bound to a
3494
3946
  // fresh engine since.
3495
3947
  engineDelegateRegistration?.let { engine ->
3496
3948
  if (engine.delegate === this) engine.delegate = null
3497
3949
  }
3950
+ // And from whatever engine is bound now. A playback-mode swap carries the
3951
+ // delegate across to the incoming engine without this registration
3952
+ // following it, so clearing only the remembered one unwires an engine
3953
+ // that has already been released and leaves the live one still
3954
+ // delivering into a torn-down player.
3955
+ serviceBinder?.engine?.let { engine ->
3956
+ if (engine.delegate === this) engine.delegate = null
3957
+ }
3498
3958
  engineDelegateRegistration = null
3499
3959
  stopProgressTick()
3500
3960
  }
@@ -3568,8 +4028,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3568
4028
  p.replaceMediaItem(idx, updated)
3569
4029
  }
3570
4030
 
3571
- override fun onError(engine: PlaybackEngine, exception: PlaybackException) {
3572
- handlePlayerError(exception)
4031
+ override fun onError(
4032
+ engine: PlaybackEngine, exception: PlaybackException, failedIndex: Int
4033
+ ) {
4034
+ handlePlayerError(exception, failedIndex)
3573
4035
  }
3574
4036
 
3575
4037
  override fun onPlaybackEnded(engine: PlaybackEngine) {
@@ -3594,13 +4056,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3594
4056
  if (incomingIndex !in tracks.indices) return
3595
4057
  currentTrackIndex = incomingIndex
3596
4058
  lastErrorKey = null
4059
+ reportedFailureIndices.retainAll(setOf(incomingIndex))
3597
4060
  val track = tracks[incomingIndex]
3598
- val cache = lookaheadCache
3599
- currentTrackSource = classifyTrackSource(track.url) { url ->
3600
- cache?.isFullyCached(url) == true
3601
- }
4061
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3602
4062
  crossfadeFadeStartEmittedIdx = incomingIndex
3603
4063
  pendingTrackChangeReason = null
4064
+ logPlaybackSource(track.url)
3604
4065
  // New active track => new milestone playthrough (the matching
3605
4066
  // structural transition echo is deduped in handleMediaItemTransition).
3606
4067
  milestoneTracker.reset()
@@ -3612,6 +4073,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3612
4073
  rescheduleLookahead()
3613
4074
  }
3614
4075
 
4076
+ /**
4077
+ * Records what the player is actually reading for the active track.
4078
+ *
4079
+ * The reported source says whether the file is on disk; it does not say the
4080
+ * bytes reaching the decoder came from there. Media3 reads through
4081
+ * [LookaheadCache]'s CacheDataSource, so a rising cached-bytes total is the
4082
+ * proof that playback is being served from the cache rather than the origin.
4083
+ */
4084
+ private fun logPlaybackSource(url: String?) {
4085
+ // Debug builds only, and never the query string: stream URLs carry auth
4086
+ // tokens there, and logcat is readable by every app on older devices.
4087
+ if (!BuildConfig.DEBUG) return
4088
+ val cachedBytes = lookaheadCache?.cachedBytesReadTotal?.get() ?: 0L
4089
+ Log.i(
4090
+ DIAG_TAG,
4091
+ "now-playing source=$currentTrackSource cachedBytesRead=$cachedBytes " +
4092
+ "url=${url?.let(PlaybackErrorMapping::stripQuery)}"
4093
+ )
4094
+ }
4095
+
3615
4096
  override fun onCrossfadeCancel(engine: PlaybackEngine, revertedIndex: Int) {
3616
4097
  // Fade-cancelled mid-flight (pause / stop / focus loss / queue
3617
4098
  // mutation). JS already received `onTrackChange(incoming,
@@ -3623,12 +4104,14 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3623
4104
  if (revertedIndex !in tracks.indices) return
3624
4105
  currentTrackIndex = revertedIndex
3625
4106
  lastErrorKey = null
4107
+ // The reported set is left alone. A cancel goes back to the track that was
4108
+ // already playing rather than announcing a new one, and the track the
4109
+ // standby leg was prerolling is still in the queue and still broken — the
4110
+ // engine seats it next. Dropping its report here would hand the listener
4111
+ // the same failure a second time when it does.
3626
4112
  crossfadeFadeStartEmittedIdx = null
3627
4113
  val track = tracks[revertedIndex]
3628
- val cache = lookaheadCache
3629
- currentTrackSource = classifyTrackSource(track.url) { url ->
3630
- cache?.isFullyCached(url) == true
3631
- }
4114
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3632
4115
  pendingTrackChangeReason = null
3633
4116
  trackChangeListeners.forEach {
3634
4117
  it(track, revertedIndex.toDouble(), TrackChangeReason.CROSSFADE_CANCELLED, null)
@@ -3642,7 +4125,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3642
4125
  val now = System.nanoTime()
3643
4126
  gaplessGapPrevEndedAtNs = now
3644
4127
  if (BuildConfig.DEBUG) {
3645
- gaplessLogLastEndedAtNs = now
3646
4128
  Log.d(
3647
4129
  GAPLESS_LOG_TAG,
3648
4130
  "item-ended trackIdx=$currentTrackIndex ts_ns=$now"
@@ -3650,7 +4132,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3650
4132
  }
3651
4133
  }
3652
4134
 
3653
-
3654
4135
  /**
3655
4136
  * Translate Media3 playback state + playWhenReady + suppression
3656
4137
  * reason into our [PlayerState] enum and fire [stateChangeListeners]
@@ -3728,7 +4209,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3728
4209
  if (index == currentTrackIndex) return
3729
4210
  currentTrackIndex = index
3730
4211
  val track = tracks[index]
4212
+ // No engine call happens on this path, so nothing downstream
4213
+ // reclassifies the source or recomputes the skip capability the
4214
+ // way handleMediaItemTransition does for a local advance.
4215
+ currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3731
4216
  trackChangeListeners.forEach { it(track, index.toDouble(), TrackChangeReason.AUTO_ADVANCE, null) }
4217
+ recomputeCapabilities()
3732
4218
  }
3733
4219
 
3734
4220
  /**
@@ -3748,6 +4234,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3748
4234
  fireEndOfTrackSleepTimer()
3749
4235
  queueEndListeners.forEach { it() }
3750
4236
  emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
4237
+ // Park the engine. Reaching the end of the queue is not the same as being
4238
+ // paused — `playWhenReady` stays set through STATE_ENDED — so a queue
4239
+ // installed afterwards would start playing without anyone asking. Repeat
4240
+ // modes wrap without ever reaching STATE_ENDED, so they are unaffected.
4241
+ serviceBinder?.engine?.pause()
4242
+ // The intent to play ends with the queue: nothing that seats a track from
4243
+ // here — a skip, a seek, a fresh queue, an engine swap — starts it until
4244
+ // play() is called again.
4245
+ wantsToPlay = false
4246
+ // The pause's inline callback stamps USER_REQUEST; ENDED is already
4247
+ // reported, so that emit deduplicates and the stamp would label the next
4248
+ // terminal emit a user action. Nobody asked for this pause.
4249
+ pendingStateChangeReason = null
3751
4250
  }
3752
4251
 
3753
4252
  /**
@@ -3764,6 +4263,16 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3764
4263
  * (user-skip), else fallback to [TrackChangeReason.SEEK]
3765
4264
  * - REASON_PLAYLIST_CHANGED → [TrackChangeReason.QUEUE_REPLACED]
3766
4265
  */
4266
+ /**
4267
+ * Whether playback of [url] is served from the disk cache: the cache
4268
+ * holds the whole file AND is enabled. Disabled, every read goes to the
4269
+ * origin whatever the disk holds, so a complete copy does not make the
4270
+ * track `CACHED`. An item whose data source opened before a toggle keeps
4271
+ * that source, so its report can differ from the flag for that one item.
4272
+ */
4273
+ private fun servedFromCache(url: String): Boolean =
4274
+ lookaheadConfig.enabled && lookaheadCache?.isFullyCached(url) == true
4275
+
3767
4276
  @VisibleForTesting
3768
4277
  internal fun handleMediaItemTransition(reason: Int) {
3769
4278
  val p = player ?: return
@@ -3781,11 +4290,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3781
4290
  // suppressed even though they're independent failures. Mirrors
3782
4291
  // iOS reset in `handleCurrentItemDidChange`.
3783
4292
  lastErrorKey = null
4293
+ // A report against some other track says nothing about the one now
4294
+ // being announced, so it goes. A report against the track being
4295
+ // announced stands — clearing it would leave nothing to hold on.
4296
+ reportedFailureIndices.retainAll(setOf(currentTrackIndex))
3784
4297
  val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
3785
- val cache = lookaheadCache
3786
- currentTrackSource = classifyTrackSource(track?.url) { url ->
3787
- cache?.isFullyCached(url) == true
3788
- }
4298
+ currentTrackSource = classifyTrackSource(track?.url, ::servedFromCache)
3789
4299
 
3790
4300
  // Crossfade post-swap echo: fade-start already fired
3791
4301
  // `onCrossfadeBegin` which dispatched the JS-facing track-
@@ -3884,20 +4394,84 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3884
4394
  * reschedule the proactive prefetcher after a
3885
4395
  * queue/skip mutation. Reads `tracks` + `currentTrackIndex` (both
3886
4396
  * @Volatile) and dispatches the next N remote tracks to
3887
- * [LookaheadCacheWriter]. No-op when the writer hasn't been built
3888
- * (configure not yet called).
4397
+ * [LookaheadCacheWriter]. Always refreshes cache protection; the prefetch
4398
+ * dispatch is skipped when the writer hasn't been built (configure not yet
4399
+ * called, or prefetch disabled).
3889
4400
  */
3890
4401
  @MainThread
3891
4402
  private fun rescheduleLookahead() {
4403
+ // Ahead of the writer guard on purpose: the cache is built at configure()
4404
+ // regardless of `lookaheadConfig.enabled`, so a consumer with prefetch off
4405
+ // still has a live cache that evicts, and it would otherwise run with an
4406
+ // empty protected set.
4407
+ refreshCacheProtection()
3892
4408
  val writer = lookaheadCacheWriter ?: return
3893
4409
  writer.reschedule(tracks, currentTrackIndex)
3894
4410
  }
3895
4411
 
4412
+ /**
4413
+ * Tell the cache which entries eviction must not take: the playing track, the
4414
+ * tracks being prefetched ahead of it, and one behind for an immediate
4415
+ * skip-back.
4416
+ *
4417
+ * Bounded deliberately — protecting the whole remaining queue would leave
4418
+ * nothing evictable and turn each commit into a cache wipe.
4419
+ */
4420
+ @MainThread
4421
+ private fun refreshCacheProtection() {
4422
+ val cache = lookaheadCache ?: return
4423
+ // Under LRU the set is discarded, and this runs on every queue mutation.
4424
+ if (!cache.honoursProtection) return
4425
+ val cur = currentTrackIndex
4426
+ if (cur < 0 || cur >= tracks.size) {
4427
+ cache.setProtectedUrls(emptySet())
4428
+ return
4429
+ }
4430
+ // The count arrives from JS as a Double, so Infinity and NaN are reachable.
4431
+ // Both survive `toInt()` as Int.MAX_VALUE / 0, and `cur + Int.MAX_VALUE`
4432
+ // wraps negative, which would make the subList bounds throw on main.
4433
+ // Bounded by MAX_PROTECTED_AHEAD as well as by the queue: a consumer count
4434
+ // at or above the queue length would protect every entry, leaving the
4435
+ // evictor nothing to reclaim and the cache growing past its budget with no
4436
+ // ceiling. The window that matters is the one around the playhead.
4437
+ val ahead = lookaheadConfig.lookaheadCount
4438
+ .takeIf { it.isFinite() }
4439
+ ?.toInt()
4440
+ ?.coerceIn(0, minOf(tracks.size, MAX_PROTECTED_AHEAD))
4441
+ ?: 0
4442
+ val lower = (cur - 1).coerceAtLeast(0)
4443
+ val upper = (cur + ahead).coerceAtMost(tracks.size - 1)
4444
+ cache.setProtectedUrls(tracks.subList(lower, upper + 1).map { it.url }.toSet())
4445
+ }
4446
+
3896
4447
  private fun syncCurrentTrackIndexFromPlayer(p: ExoPlayer) {
3897
4448
  val playerIndex = p.currentMediaItemIndex
3898
4449
  currentTrackIndex = if (playerIndex in tracks.indices) playerIndex else -1
3899
4450
  }
3900
4451
 
4452
+ /** Scheduled auto-retry, held so teardown can cancel it. */
4453
+ private var pendingRetryRunnable: Runnable? = null
4454
+
4455
+ /**
4456
+ * Track index whose automatic attempt is scheduled but has not run yet. A
4457
+ * failure naming it is an echo of the one being retried, so it is not
4458
+ * reported while the attempt is still owed.
4459
+ */
4460
+ private var pendingRetryIndex: Int = -1
4461
+
4462
+ /**
4463
+ * Drop a scheduled automatic attempt.
4464
+ *
4465
+ * Called wherever the queue changes, because the attempt is keyed by
4466
+ * position: after a mutation that index names a different track, and both
4467
+ * the attempt itself and the echo suppression would then be aimed at it.
4468
+ */
4469
+ private fun cancelPendingRetry() {
4470
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4471
+ pendingRetryRunnable = null
4472
+ pendingRetryIndex = -1
4473
+ }
4474
+
3901
4475
  /**
3902
4476
  * Translate [PlaybackException] into a [PlaybackError] + emit on
3903
4477
  * both [errorListeners] and as a terminal ERROR state. Mirrors iOS
@@ -3905,27 +4479,67 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3905
4479
  * conditions surface as non-fatal so consumers can offer retry
3906
4480
  * UX; codec / decoding errors surface as fatal.
3907
4481
  */
3908
- private fun handlePlayerError(error: PlaybackException) {
4482
+ private fun handlePlayerError(error: PlaybackException, failedIndex: Int = -1) {
3909
4483
  val mapped = PlaybackErrorMapping.classify(error)
3910
- // Dedup by (currentTrackIndex, standardized code). Mirrors iOS
4484
+ // Attribute to the track that actually failed. Under crossfade the
4485
+ // standby leg can fail while prerolling an upcoming track, and the
4486
+ // leading leg plays on — keying any of the three uses below off
4487
+ // currentTrackIndex would dedup against the wrong track, spend the wrong
4488
+ // track's retry budget, and report the wrong url to the consumer.
4489
+ val idx = if (failedIndex in tracks.indices) failedIndex else currentTrackIndex
4490
+ // Dedup by (failing index, standardized code). Mirrors iOS
3911
4491
  // `(queueItemId, code)` dedup. ExoPlayer can fire onPlayerError
3912
4492
  // multiple times for a single failed item under retry storms.
3913
- val key = currentTrackIndex to mapped
4493
+ val key = idx to mapped
4494
+ // An echo of a failure whose attempt has not run yet belongs to that
4495
+ // attempt, not to the consumer.
4496
+ if (idx >= 0 && idx == pendingRetryIndex) return
4497
+ // Already reported and not yet recovered. Under crossfade the standby leg
4498
+ // prerolls the next track, so a broken one is reported while the leading
4499
+ // track still plays; the engine then seats it and it fails again as the
4500
+ // current track. `lastErrorKey` cannot carry that on its own — the track
4501
+ // change between the two clears it — so one thing asked for would report
4502
+ // twice. The state still has to be announced when this becomes the track
4503
+ // the listener is on, because the report was raised before it was.
4504
+ if (idx >= 0 && idx in reportedFailureIndices) {
4505
+ if (idx == currentTrackIndex) emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4506
+ return
4507
+ }
4508
+ // Once the queue has stopped on the current track, the tracks behind it go
4509
+ // quiet: the listener has been told the track they are on failed, and one
4510
+ // error per unplayable track in the run is noise they cannot act on. While
4511
+ // playback is healthy this does not fire, so a standby leg failing to
4512
+ // preroll under crossfade is still surfaced. Mirrors iOS.
4513
+ if (idx >= 0 && idx != currentTrackIndex &&
4514
+ currentTrackIndex in reportedFailureIndices) {
4515
+ return
4516
+ }
3914
4517
  if (lastErrorKey == key) return
3915
4518
  lastErrorKey = key
3916
4519
  // Auto-retry path: transient errors with retries left
3917
4520
  // schedule a delayed `prepare()`. Skip the JS error emit + state
3918
4521
  // ERROR transition until retries exhausted; consumer sees onError
3919
4522
  // only when the lib has given up. Mirrors iOS path.
3920
- val idx = currentTrackIndex
4523
+ // Only the track being played is retried. A standby leg's preroll failing
4524
+ // is the *next* track failing to load early, and the cancel that precedes
4525
+ // the report reverts `currentTrackIndex` to the leading track, so an
4526
+ // attempt scheduled for it could never run. It is surfaced instead, and
4527
+ // gets its own automatic attempt when it becomes current and loads fresh.
3921
4528
  if (idx >= 0 &&
4529
+ idx == currentTrackIndex &&
3922
4530
  PlaybackErrorMapping.isTransient(mapped) &&
3923
4531
  (retryAttemptsRemaining[idx] ?: 0) > 0) {
3924
4532
  retryAttemptsRemaining[idx] = retryAttemptsRemaining[idx]!! - 1
3925
4533
  // Clear dedup so the retry-then-fail (if any) fires fresh.
3926
4534
  lastErrorKey = null
3927
4535
  val backoff = effectiveRetryBackoffMs()
3928
- mainHandler.postDelayed({ retryFailedItem(idx) }, backoff.toLong())
4536
+ // Hold the runnable so teardown can drop it. A retry posted moments
4537
+ // before destroy() would otherwise fire against a released engine.
4538
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4539
+ val retry = Runnable { retryFailedItem(idx) }
4540
+ pendingRetryRunnable = retry
4541
+ pendingRetryIndex = idx
4542
+ mainHandler.postDelayed(retry, backoff.toLong())
3929
4543
  return
3930
4544
  }
3931
4545
  // Surface the failing track's URL so consumers can correlate the
@@ -3938,17 +4552,28 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3938
4552
  val err = PlaybackErrorMapping.buildPlaybackError(
3939
4553
  error, mapped, queueItemId = "", url = failedUrl,
3940
4554
  )
4555
+ // The consumer now knows this track failed, so the queue holds on it until
4556
+ // something asks for playback again. Recorded for whichever track failed,
4557
+ // current or not: that is what keeps the standby leg's report from being
4558
+ // handed to the listener a second time when the engine seats it.
4559
+ if (idx >= 0) reportedFailureIndices.add(idx)
3941
4560
  errorListeners.forEach { it(err) }
3942
- emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4561
+ // The state is the current track's. A standby leg's preroll failing is
4562
+ // reported above and leaves the leading leg playing; announcing ERROR for
4563
+ // it would put a terminal state under audio that is still running, and
4564
+ // the next engine callback would flip it straight back to PLAYING.
4565
+ if (idx == currentTrackIndex) {
4566
+ emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4567
+ }
3943
4568
  }
3944
4569
 
3945
4570
  // --- Auto-retry + stuck-recovery
3946
4571
 
3947
4572
  /** Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
3948
- * defaults to 3, clamps to [0, 5]. */
4573
+ * defaults to 1, clamps to [0, 5]. */
3949
4574
  @VisibleForTesting
3950
4575
  internal fun effectiveAutoRetries(): Int =
3951
- (config.autoRetries?.toInt() ?: 3).coerceIn(0, 5)
4576
+ (config.autoRetries?.toInt() ?: 1).coerceIn(0, 5)
3952
4577
 
3953
4578
  /** Lib-clamped retry backoff in milliseconds. Defaults to 500,
3954
4579
  * clamps to [200, 5000]. Floor at 200ms prevents retry storms
@@ -3959,31 +4584,56 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3959
4584
 
3960
4585
  /** Try to recover from a transient failure by re-preparing the
3961
4586
  * ExoPlayer at the current position. Called from
3962
- * [handlePlayerError] (auto-retry) AND from [play] (one-shot
3963
- * stuck-recovery). Mirrors iOS `retryFailedItem` which does a
3964
- * full AVQueuePlayer rebuild via `fullRebuildPlayerQueue`.
4587
+ * [handlePlayerError] (auto-retry) and from [retry] (the
4588
+ * consumer-facing one-shot). Mirrors iOS `retryFailedItem`, which
4589
+ * rebuilds the AVQueuePlayer via `fullRebuildPlayerQueue` and seeks
4590
+ * back to the captured position.
3965
4591
  *
3966
- * Implementation: `setMediaItem(currentMediaItem, false) +
3967
- * prepare()` rather than bare `prepare()` — the latter re-uses
3968
- * the existing wedged DataSource on a "Cannot Open"-class
3969
- * failure and may not actually recover. Re-setting the
3970
- * MediaItem forces Media3 to construct a fresh DataSource from
3971
- * the MediaSource.Factory (carries the current `httpFactory`
3972
- * + cache wrapper). `resetPosition=false` preserves the
3973
- * current playback position; iOS rebuild resets to 0 (mid-play
3974
- * failure loses position regardless). For initial-load failures
3975
- * the position is 0 anyway, so the position-preservation here
3976
- * is the only intentional iOS/Android divergence. */
4592
+ * Implementation: remove + re-add the failed item at its index,
4593
+ * `seekTo` the captured position, then `prepare()` — rather than
4594
+ * bare `prepare()`, which re-uses the existing wedged DataSource
4595
+ * on a "Cannot Open"-class failure and may not actually recover.
4596
+ * Re-adding the item forces Media3 to construct a fresh DataSource
4597
+ * from the MediaSource.Factory (carries the current `httpFactory`
4598
+ * + cache wrapper). */
3977
4599
  internal fun retryFailedItem(atIndex: Int) {
3978
- val p = player ?: return
3979
- // Verify the failed item is still the current one. This also bounds the
3980
- // rebuild to the leading leg's active item: mid-fade the leading leg is
3981
- // the OUTGOING track, whose index differs from currentTrackIndex (the
3982
- // fade-flipped incoming track), so a retry mid-fade is skipped here.
4600
+ // The attempt has arrived, so whatever fails next is a new failure rather
4601
+ // than an echo of the one being retried. A scheduled attempt for the same
4602
+ // index is withdrawn with it: left posted, it would fire after the backoff
4603
+ // and rebuild the track a second time behind a user's own retry.
4604
+ if (pendingRetryIndex == atIndex) {
4605
+ pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
4606
+ pendingRetryIndex = -1
4607
+ pendingRetryRunnable = null
4608
+ }
4609
+ // The attempt re-seats the item, so the queue is no longer stopped on it.
4610
+ reportedFailureIndices.remove(atIndex)
4611
+ // The failed item must still be the current one; a skip or a queue mutation
4612
+ // since the failure makes the retry moot. Checked before the fade is
4613
+ // settled, because settling is an audible cut and a retry that is about to
4614
+ // return has no business making one.
3983
4615
  if (atIndex != currentTrackIndex) return
4616
+ // Settle a fade before reading the player. `player` is the leading leg,
4617
+ // which mid-fade holds the OUTGOING track while `currentTrackIndex` has
4618
+ // already flipped to the incoming one — so every read below would describe
4619
+ // a different track than the index check just approved, and a retry would
4620
+ // rebuild and restart the track the user is leaving. Promoting first makes
4621
+ // the leading leg the track being heard, which is the one a retry is for.
4622
+ serviceBinder?.engine?.settlePendingFadeByPromoting()
4623
+ val p = player ?: return
3984
4624
  val current = p.currentMediaItem
3985
4625
  if (current != null) {
3986
- val idx = p.currentMediaItemIndex
4626
+ // Act on the index that was approved above, not on whatever the engine
4627
+ // happens to be pointing at. They agree in every path found so far — the
4628
+ // standby leg carries the whole queue so its index is in the queue's own
4629
+ // space, and the settle above puts the leading leg on the current track —
4630
+ // but nothing enforces it, and a disagreement means the engine has not
4631
+ // caught up with a mutation. Rebuilding at either index would then be
4632
+ // wrong: the track being heard gets torn down and restarted at another
4633
+ // track's position, because `resumeMs` below is that track's. Refuse
4634
+ // instead; whatever asks next attempts it again.
4635
+ if (atIndex >= p.mediaItemCount || p.currentMediaItemIndex != atIndex) return
4636
+ val idx = atIndex
3987
4637
  val resumeMs = p.currentPosition.coerceAtLeast(0L)
3988
4638
  // Force a FRESH MediaSource/DataSource for the failed item without
3989
4639
  // collapsing the rest of the timeline. `replaceMediaItem(idx, current)`
@@ -4002,10 +4652,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4002
4652
  } else {
4003
4653
  p.prepare()
4004
4654
  }
4005
- // Resume playback if the user had been playing pre-failure.
4006
- // (Live-state check; the pre-failure playWhenReady wasn't
4007
- // captured)
4008
- if (!p.isPlaying) serviceBinder?.engine?.play()
4655
+ // Resume only if playback is wanted NOW, not at failure time: a manual
4656
+ // retry() carries no failure-time flag, and a pause during the retry
4657
+ // backoff must win over the state the failure was scheduled under.
4658
+ if (wantsToPlay && !p.isPlaying) serviceBinder?.engine?.play()
4009
4659
  }
4010
4660
 
4011
4661
  /**
@@ -4268,13 +4918,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4268
4918
  if (Looper.myLooper() == Looper.getMainLooper()) {
4269
4919
  body()
4270
4920
  } else {
4271
- val latch = CountDownLatch(1)
4921
+ val done = CountDownLatch(1)
4272
4922
  var err: Throwable? = null
4273
4923
  mainHandler.post {
4274
4924
  try { body() } catch (t: Throwable) { err = t }
4275
- latch.countDown()
4925
+ done.countDown()
4276
4926
  }
4277
- latch.await()
4927
+ done.await()
4278
4928
  err?.let { throw it }
4279
4929
  }
4280
4930
  }
@@ -4299,18 +4949,45 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4299
4949
  }
4300
4950
 
4301
4951
  companion object {
4952
+ /** Prefs holding the eviction policy the on-disk cache was built with. */
4953
+ private const val CACHE_PREFS_NAME = "rnqp_lookahead_cache"
4954
+ private const val KEY_EVICTION_POLICY = "eviction_policy"
4955
+
4956
+ /** Marks a cache directory renamed aside and awaiting deletion. */
4957
+ private const val DELETING_DIR_SUFFIX = ".deleting-"
4958
+
4959
+ /**
4960
+ * Renamed cache directories this process is currently deleting.
4961
+ *
4962
+ * The sweep collects anything wearing the deleting- prefix, which is
4963
+ * exactly the name a discard just produced — so without this it starts a
4964
+ * second delete of a tree already being deleted, with a second database
4965
+ * provider dropping the same index tables underneath the first.
4966
+ *
4967
+ * Process-wide because the cache is: a second [TrackPlayer] configured in
4968
+ * the same process sweeps the same directory. Held only while a delete is
4969
+ * in flight, so a directory abandoned by a previous process — the case the
4970
+ * sweep exists for — is never claimed and is always collected.
4971
+ */
4972
+ private val doomedCacheDirectories = java.util.Collections.synchronizedSet(
4973
+ mutableSetOf<String>())
4974
+
4975
+ private fun claimDoomedDirectory(dir: java.io.File) {
4976
+ doomedCacheDirectories.add(dir.absolutePath)
4977
+ }
4978
+
4979
+ private fun releaseDoomedDirectory(dir: java.io.File) {
4980
+ doomedCacheDirectories.remove(dir.absolutePath)
4981
+ }
4982
+
4983
+ private fun isDoomedDirectoryClaimed(dir: java.io.File): Boolean =
4984
+ doomedCacheDirectories.contains(dir.absolutePath)
4985
+
4302
4986
  /** Progress tick cadence — matches iOS 2 Hz. */
4303
4987
  private const val PROGRESS_TICK_INTERVAL_MS = 500L
4304
4988
  /** Sleep-timer countdown/fade cadence — 500ms gives a smooth 10s fade. */
4305
4989
  private const val SLEEP_TIMER_TICK_INTERVAL_MS = 500L
4306
4990
 
4307
- /**
4308
- * Disposer returned from `on*Request` registrations when the
4309
- * `PlaybackService` isn't bound yet. Calling it is a no-op —
4310
- * the registration never landed, so there's nothing to clear.
4311
- */
4312
- private val NO_OP_DISPOSER: () -> Unit = {}
4313
-
4314
4991
  /** Tolerance for "bufferedPosition has reached duration" — a CBR-estimated
4315
4992
  * duration or rounding can leave a sub-second gap when fully downloaded. */
4316
4993
  private const val FULLY_BUFFERED_EPSILON_MS = 1000L
@@ -4320,6 +4997,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4320
4997
  * reference one named constant. */
4321
4998
  private const val MS_PER_SECOND = 1000.0
4322
4999
 
5000
+ /**
5001
+ * Upper bound on how far ahead of the playhead cache eviction is blocked.
5002
+ *
5003
+ * Independent of the consumer's lookahead count on purpose: protecting the
5004
+ * whole queue leaves the evictor nothing to reclaim, and the cache then
5005
+ * grows past its budget with no ceiling.
5006
+ */
5007
+ private const val MAX_PROTECTED_AHEAD = 10
5008
+
4323
5009
  /**
4324
5010
  * Tag for the verbose gapless-transition logs (item-ended /
4325
5011
  * item-changed / playing). All emit sites are guarded by
@@ -4328,6 +5014,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4328
5014
  */
4329
5015
  private const val GAPLESS_LOG_TAG = "RNQP-GAPLESS"
4330
5016
 
5017
+ /** Playback-source diagnostics: what the player actually reads from. */
5018
+ private const val DIAG_TAG = "RNQP-DIAG"
5019
+
4331
5020
  /** Logcat tag for player-level lifecycle warnings. Part of the
4332
5021
  * `QueuePlayer.<area>` hierarchy used across the lib's Android
4333
5022
  * sources. */
@@ -4347,6 +5036,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4347
5036
  var current: TrackPlayer? = null
4348
5037
  private set
4349
5038
 
5039
+ init {
5040
+ // One process-wide listener on the cast route, forwarding to whichever
5041
+ // instance is configured. Push-PCM sessions (AirPlay 1/2) force-degrade
5042
+ // the active engine from Crossfade to Gapless because the wire protocol
5043
+ // cannot mix two ExoPlayers into one sink; clearing the session
5044
+ // re-applies the user's requested mode (so crossfade re-instates).
5045
+ // Registered once here rather than per instance: `CastSinkRouter` is a
5046
+ // process-wide object, and a per-instance listener kept every instance
5047
+ // ever constructed reachable and ran the swap on all of them.
5048
+ com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
5049
+ val player = current ?: return@addRouteChangeListener
5050
+ // Re-apply against the user's requested mode on every flip.
5051
+ // `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
5052
+ // for the degrade decision, so the swap settles to the right
5053
+ // engine for the new route state.
5054
+ val mode = player.playbackModeState.active
5055
+ player.mainHandler.post { player.applyEngineSwapForMode(mode) }
5056
+ }
5057
+ }
5058
+
4350
5059
  /**
4351
5060
  * Classify the active-track playback source. `null` when the URL
4352
5061
  * is null or its scheme is neither `file://` nor `http(s)://`