react-native-queue-player 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/build.gradle +7 -0
  4. package/android/consumer-rules.pro +72 -3
  5. package/android/src/main/cpp/airplay2_control.c +4 -4
  6. package/android/src/main/cpp/airplay2_control.h +4 -4
  7. package/android/src/main/cpp/airplay2_jni.cpp +26 -22
  8. package/android/src/main/cpp/airplay2_pair.c +7 -7
  9. package/android/src/main/cpp/airplay2_pair.h +2 -2
  10. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  11. package/android/src/main/cpp/airplay2_session.c +2 -2
  12. package/android/src/main/cpp/airplay_jni.cpp +20 -72
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +870 -278
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +23 -12
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  61. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  62. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  63. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  64. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  65. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +23 -12
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +8 -4
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +163 -0
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  114. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  115. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  116. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  117. package/app.plugin.js +17 -1
  118. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  119. package/ios/AVQueueBuilder.swift +120 -85
  120. package/ios/ArtworkLoader.swift +0 -7
  121. package/ios/ArtworkResolver.swift +22 -4
  122. package/ios/AssetReplayGainReader.swift +118 -0
  123. package/ios/AudioSession.swift +67 -16
  124. package/ios/AudioTapProvider.swift +284 -149
  125. package/ios/CarPlayBridge.swift +0 -12
  126. package/ios/CarPlayCoordinator.swift +18 -2
  127. package/ios/CarPlaySceneDelegate.swift +2 -2
  128. package/ios/Cast/AirPlayRouteState.swift +9 -8
  129. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  130. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  131. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  132. package/ios/Cast/Core/CastSession.swift +11 -0
  133. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  134. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  135. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  136. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  137. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  138. package/ios/CastManager.swift +1 -1
  139. package/ios/CrossfadeEngine.swift +660 -226
  140. package/ios/EQTap.swift +20 -17
  141. package/ios/Equalizer.swift +1 -1
  142. package/ios/FLACStreamInfo.swift +65 -0
  143. package/ios/GaplessEngine.swift +57 -30
  144. package/ios/InputGuards.swift +35 -5
  145. package/ios/LookaheadCache.swift +202 -58
  146. package/ios/LookaheadCachePrefetcher.swift +48 -36
  147. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  148. package/ios/NetworkRecoveryPolicy.swift +32 -0
  149. package/ios/NowPlayingFormatExtractor.swift +25 -18
  150. package/ios/NowPlayingInfo.swift +84 -48
  151. package/ios/OutputRouteMonitor.swift +16 -3
  152. package/ios/PendingBrowseRequests.swift +1 -1
  153. package/ios/PlaceholderArtwork.swift +9 -5
  154. package/ios/PlaybackEngine.swift +165 -76
  155. package/ios/PlaybackErrorMapping.swift +3 -3
  156. package/ios/PlaybackModeStateMachine.swift +2 -2
  157. package/ios/PlaybackNetworkMonitor.swift +73 -0
  158. package/ios/PlayerStateDerivation.swift +6 -6
  159. package/ios/QueueMutationArithmetic.swift +25 -0
  160. package/ios/QueueSkipArithmetic.swift +5 -3
  161. package/ios/QueueWindowArithmetic.swift +152 -0
  162. package/ios/ReadThroughServer.swift +283 -0
  163. package/ios/RemoteCommands.swift +44 -4
  164. package/ios/ReplayGainData.swift +22 -3
  165. package/ios/ReplayGainExtractor.swift +70 -27
  166. package/ios/Siri/VoiceDonation.swift +14 -9
  167. package/ios/StreamingBitrateProbe.swift +22 -68
  168. package/ios/SuppliedReplayGain.swift +83 -0
  169. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  170. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  171. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  172. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  173. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  174. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  175. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  176. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  177. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  178. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  179. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  180. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  181. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  182. package/ios/Tests/EngineSwapTests.swift +91 -0
  183. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  184. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  185. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  186. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  187. package/ios/Tests/InputGuardsTests.swift +41 -0
  188. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  189. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  190. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  191. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  192. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  193. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  194. package/ios/Tests/MutationDeferralTests.swift +220 -0
  195. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  196. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  197. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  198. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  199. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  200. package/ios/Tests/PlayerFixtures.swift +48 -0
  201. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  202. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  203. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  204. package/ios/Tests/QueueStateTests.swift +3 -1
  205. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  206. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  207. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  208. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  209. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  210. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  211. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  212. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  213. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  214. package/ios/Tests/SkipIndexTests.swift +32 -2
  215. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  216. package/ios/Tests/StallRecoveryTests.swift +97 -0
  217. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  218. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  219. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  220. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  221. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  222. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  223. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  224. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  225. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  226. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  227. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  228. package/ios/TrackPlayer+Automotive.swift +101 -0
  229. package/ios/TrackPlayer+Cache.swift +232 -0
  230. package/ios/TrackPlayer+Config.swift +291 -0
  231. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  232. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  233. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  234. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  235. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  236. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  237. package/ios/TrackPlayer+Queue.swift +940 -0
  238. package/ios/TrackPlayer+Recovery.swift +196 -0
  239. package/ios/TrackPlayer+Skip.swift +455 -0
  240. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  241. package/ios/TrackPlayer+State.swift +108 -0
  242. package/ios/TrackPlayer+Threading.swift +135 -0
  243. package/ios/TrackPlayer+Transport.swift +267 -0
  244. package/ios/TrackPlayer+Window.swift +195 -0
  245. package/ios/TrackPlayer.swift +305 -4610
  246. package/ios/Visualizer.swift +6 -5
  247. package/ios/tests-harness/Podfile +1 -1
  248. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  249. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  250. package/lib/module/hooks/useActiveTrack.js +29 -22
  251. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  252. package/lib/module/hooks/useCast.js +8 -26
  253. package/lib/module/hooks/useCast.js.map +1 -1
  254. package/lib/module/hooks/useEqualizer.js +19 -12
  255. package/lib/module/hooks/useEqualizer.js.map +1 -1
  256. package/lib/module/hooks/useLookaheadCache.js +3 -7
  257. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  258. package/lib/module/hooks/useQueue.js +66 -19
  259. package/lib/module/hooks/useQueue.js.map +1 -1
  260. package/lib/module/index.js +6 -3
  261. package/lib/module/index.js.map +1 -1
  262. package/lib/module/queueDelta.js +41 -0
  263. package/lib/module/queueDelta.js.map +1 -0
  264. package/lib/module/types.js +28 -5
  265. package/lib/module/types.js.map +1 -1
  266. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  267. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  269. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useCast.d.ts +6 -1
  271. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  272. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  273. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  274. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  275. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  276. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  277. package/lib/typescript/index.d.ts +2 -1
  278. package/lib/typescript/index.d.ts.map +1 -1
  279. package/lib/typescript/queueDelta.d.ts +10 -0
  280. package/lib/typescript/queueDelta.d.ts.map +1 -0
  281. package/lib/typescript/types.d.ts +168 -12
  282. package/lib/typescript/types.d.ts.map +1 -1
  283. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  284. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  285. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  286. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  287. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  288. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  289. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  320. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  321. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  322. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  323. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  324. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  325. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  326. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  327. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  328. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  329. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  330. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  331. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  332. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  333. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  334. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  335. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  336. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  337. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  338. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  339. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  340. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  341. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  342. package/package.json +8 -8
  343. package/src/TrackPlayer.nitro.ts +53 -27
  344. package/src/hooks/useActiveTrack.ts +29 -22
  345. package/src/hooks/useCast.ts +14 -15
  346. package/src/hooks/useEqualizer.ts +19 -12
  347. package/src/hooks/useLookaheadCache.ts +3 -7
  348. package/src/hooks/useQueue.ts +66 -17
  349. package/src/index.ts +13 -3
  350. package/src/queueDelta.ts +41 -0
  351. package/src/types.ts +169 -12
  352. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  353. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
  354. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  355. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  356. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  357. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  358. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  359. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  360. package/ios/MetadataReader.swift +0 -555
  361. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  362. package/ios/Tests/MetadataReaderTests.swift +0 -603
  363. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  364. package/ios/Tests/VoiceDonationTests.swift +0 -26
  365. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  366. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  367. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,196 @@
1
+ import UIKit
2
+
3
+ extension TrackPlayer {
4
+ // MARK: - Auto-retry + stuck-recovery
5
+
6
+ /// Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
7
+ /// defaults to 1, clamps to [0, 5]. Called from `setQueue` /
8
+ /// `addToQueueBody` to populate `retryAttemptsRemaining`.
9
+ ///
10
+ /// Non-finite (NaN / ±Infinity) inputs from JS would trap at
11
+ /// `Int(Double.nan)` — Swift's Int initialiser preconditionFails
12
+ /// rather than rounding to 0 like Kotlin. Mirror Android's
13
+ /// `coerceIn` behaviour by gating on `isFinite` first.
14
+ internal func effectiveAutoRetries() -> Int {
15
+ guard let raw = self.config.autoRetries, raw.isFinite else { return 1 }
16
+ // Clamp before converting: `Int(_:)` traps on a finite value too large for
17
+ // `Int`, so the bound has to be applied in `Double` space.
18
+ return Int(max(0, min(5, raw)))
19
+ }
20
+
21
+ /// Lib-clamped retry backoff in milliseconds. Reads from
22
+ /// `config.retryBackoffMs`, defaults to 500, clamps to [200, 5000].
23
+ /// Floor at 200ms prevents retry storms against misbehaving servers
24
+ /// (e.g. 503 with `Retry-After: 0`). Same `isFinite` gate as
25
+ /// `effectiveAutoRetries`.
26
+ internal func effectiveRetryBackoffMs() -> Int {
27
+ guard let raw = self.config.retryBackoffMs, raw.isFinite else { return 500 }
28
+ return Int(max(200, min(5000, raw)))
29
+ }
30
+
31
+ // MARK: - Progress-emission throttle
32
+
33
+ /// Monotonic clock in milliseconds for the progress-emission throttle.
34
+ /// `systemUptime` is monotonic and needs no extra framework import.
35
+ internal func monotonicNowMs() -> Double {
36
+ ProcessInfo.processInfo.systemUptime * 1000
37
+ }
38
+
39
+ /// Foreground `onProgress` cadence in ms. Reads
40
+ /// `config.progressUpdateIntervalMs`, defaults to 500, floors at the 500 ms
41
+ /// native tick rate, and ignores non-finite / non-positive inputs.
42
+ private func effectiveForegroundProgressIntervalMs() -> Double {
43
+ guard let raw = self.config.progressUpdateIntervalMs, raw.isFinite, raw > 0 else { return 500 }
44
+ return max(500, raw)
45
+ }
46
+
47
+ /// Background `onProgress` cadence in ms. Reads
48
+ /// `config.backgroundProgressUpdateIntervalMs`, defaults to the effective
49
+ /// foreground value (⇒ no background reduction), floors at 500, ignores
50
+ /// non-finite / non-positive inputs.
51
+ private func effectiveBackgroundProgressIntervalMs() -> Double {
52
+ let foreground = effectiveForegroundProgressIntervalMs()
53
+ guard let raw = self.config.backgroundProgressUpdateIntervalMs, raw.isFinite, raw > 0 else {
54
+ return foreground
55
+ }
56
+ return max(500, raw)
57
+ }
58
+
59
+ /// Push the configured foreground + background cadences into the throttle.
60
+ /// Called on every `configure()`; the gate preserves its runtime state.
61
+ internal func applyProgressEmissionIntervals() {
62
+ self.progressEmissionGate.setIntervals(
63
+ foregroundMs: effectiveForegroundProgressIntervalMs(),
64
+ backgroundMs: effectiveBackgroundProgressIntervalMs(),
65
+ samplerPeriodMs: Self.progressIntervalSeconds * 1000)
66
+ }
67
+
68
+ /// Observe app background/foreground so the throttle can switch cadence.
69
+ /// Idempotent — a no-op when already installed. Seeds the current state so a
70
+ /// `configure()` issued while already backgrounded starts throttled.
71
+ internal func installAppLifecycleObservers() {
72
+ guard appLifecycleObservers.isEmpty else { return }
73
+ // `applicationState` is a main-thread API and this runs on `playerQueue`:
74
+ // read it on main and hand the value back. The seed lands a hop later,
75
+ // so the first progress ticks of a configure issued in the background
76
+ // may go out at the foreground cadence.
77
+ DispatchQueue.main.async { [weak self] in
78
+ let backgrounded = UIApplication.shared.applicationState == .background
79
+ self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(backgrounded) }
80
+ }
81
+ let center = NotificationCenter.default
82
+ appLifecycleObservers.append(
83
+ center.addObserver(
84
+ forName: UIApplication.didEnterBackgroundNotification,
85
+ object: nil, queue: .main
86
+ ) { [weak self] _ in
87
+ // The gate is player state; the notification arrives on main.
88
+ self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(true) }
89
+ })
90
+ appLifecycleObservers.append(
91
+ center.addObserver(
92
+ forName: UIApplication.willEnterForegroundNotification,
93
+ object: nil, queue: .main
94
+ ) { [weak self] _ in
95
+ // The gate is player state; the notification arrives on main.
96
+ self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(false) }
97
+ })
98
+ }
99
+
100
+ /// Remove the app-lifecycle observers registered by
101
+ /// `installAppLifecycleObservers`. Idempotent.
102
+ internal func removeAppLifecycleObservers() {
103
+ let center = NotificationCenter.default
104
+ appLifecycleObservers.forEach { center.removeObserver($0) }
105
+ appLifecycleObservers.removeAll()
106
+ }
107
+
108
+ /// Retry the current item when the network comes back after an outage.
109
+ ///
110
+ /// The common real-world case is a tunnel, a lift, or airplane mode: the
111
+ /// retries burn while there is no connection, the item ends up failed, and
112
+ /// nothing tries again — the user has to notice and press play. This runs
113
+ /// recovery at the moment it can actually succeed.
114
+ ///
115
+ /// The guards are the whole design. Intent and interruption are checked as
116
+ /// everywhere else, and the retry budget is refunded for **that one track
117
+ /// only** rather than reset wholesale: a flapping connection would otherwise
118
+ /// hand out unlimited retries and defeat the budget entirely.
119
+ internal func handleNetworkRestored() {
120
+ guard self.currentTrackIndex >= 0,
121
+ self.currentTrackIndex < self.queueItemIds.count else { return }
122
+ let qid = self.queueItemIds[self.currentTrackIndex]
123
+ // The unrecovered-failure test is the error key naming *this* track, not
124
+ // the last reported state: by the time a network comes back the announced
125
+ // sequence has settled on `paused`, or on `ended` when the queue drained.
126
+ // The key is cleared by every recovery path, so it is set only while this
127
+ // track is still broken.
128
+ guard NetworkRecoveryPolicy.shouldRetry(
129
+ wantsToPlay: self.wantsToPlay,
130
+ isInterrupted: self.isInterrupted,
131
+ isInError: self.lastErrorQueueItemId == qid,
132
+ isTransient: self.lastErrorCode.map(PlaybackErrorMapping.isTransient) ?? false
133
+ ) else { return }
134
+ if (self.retryAttemptsRemaining[qid] ?? 0) == 0 {
135
+ self.retryAttemptsRemaining[qid] = 1
136
+ }
137
+ self.lastErrorQueueItemId = nil
138
+ self.lastErrorCode = nil
139
+ self.retryFailedItem(queueItemId: qid)
140
+ }
141
+
142
+ /// Try to recover from a transient failure by reinstalling the engine's
143
+ /// items at the current position. Called from the transient auto-retry
144
+ /// path in `dispatchErrorOrRetry` and from `handleNetworkRestored`.
145
+ ///
146
+ /// Implementation: `fullRebuildPlayerQueue(forcingReinstall: true)` with
147
+ /// the current index unchanged. The force is load-bearing: a failed item
148
+ /// keeps its `queueItemId`, so an identity comparison would find the
149
+ /// slice already correct and reinstall nothing. Fresh AVPlayerItems are
150
+ /// built through `AVQueueBuilder.buildPlayerItems` on the existing
151
+ /// `queueItemIds` (so dedup keys remain stable), then playback seeks
152
+ /// back to the captured position.
153
+ ///
154
+ /// Gated on the engine, not on `self.player`: the latter is the gapless
155
+ /// queue player and is nil under crossfade.
156
+ internal func retryFailedItem(queueItemId qid: String) {
157
+ // The attempt has arrived, so whatever fails next is a new failure rather
158
+ // than an echo of the one being retried.
159
+ if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
160
+ guard let engine = self.engine else { return }
161
+ // Verify the failed item is still the current one. A subsequent
162
+ // user skip / queue mutation could have moved past it; in that
163
+ // case the retry is moot.
164
+ guard let idx = self.queueItemIds.firstIndex(of: qid),
165
+ idx == self.currentTrackIndex else { return }
166
+ // A retry is an explicit intent to play this track again, so it clears
167
+ // `reachedQueueEnd` exactly as a seek or a skip does. Without this, a
168
+ // queue whose only track failed to load has already drained, been called
169
+ // ended and had its engine parked — so the rebuild below seats an item
170
+ // into a queue that believes it is finished, and playback never resumes.
171
+ self.reachedQueueEnd = false
172
+ // Captured before the rebuild, which reloads the item from zero: a
173
+ // transient blip well into a long track must not restart it.
174
+ let resumePosition = engine.currentPositionSeconds
175
+ // Forced: this exists to replace an item that failed, and a failed item
176
+ // keeps its `queueItemId`, so an identity comparison would find the slice
177
+ // already correct and rebuild nothing.
178
+ self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
179
+ // No seek beats a seek to the wrong track: with a trail installed,
180
+ // engine-index 0 is a track already played rather than the failed one.
181
+ if resumePosition.isFinite, resumePosition > 0,
182
+ let engineIdx = self.engineIndex(forQueuePosition: idx) {
183
+ engine.seek(toIndex: engineIdx, position: resumePosition)
184
+ }
185
+ // Resume only what was actually running. A transient load failure can
186
+ // arrive while the user is paused, and the rebuild leaves the item seated
187
+ // at rate 0 either way, so the transport intent is what separates
188
+ // "recover playback" from "start playing something nobody asked for".
189
+ // Through the engine so the user's playback speed is restored (a raw
190
+ // AVPlayer.play() resets rate to 1.0).
191
+ // An auto-retry completing mid-interruption must not start audio.
192
+ if self.wantsToPlay, !self.isInterrupted, engine.timeControlStatus != .playing {
193
+ self.beginPlayback()
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,455 @@
1
+ import NitroModules
2
+
3
+ extension TrackPlayer {
4
+ // MARK: - Skip
5
+ //
6
+ // AVQueuePlayer is forward-only, so skipToPrevious and skipToIndex
7
+ // (when target < current) both require rebuilding the player's
8
+ // queue from the authoritative `self.tracks` list at the new
9
+ // index. skipToNext is a surgical `player.advanceToNextItem()`
10
+ // when the next track is already queued; wraps round (via full
11
+ // rebuild) when `repeatModeState == .queue` and we're past the
12
+ // end.
13
+ //
14
+ // Shuffle: destructive action `shuffleQueue()`. Mutates
15
+ // `self.tracks` to a Fisher-Yates permutation, resets
16
+ // `currentTrackIndex` to 0, resumes playback if was playing, and
17
+ // returns the new ordering to the consumer. Skip arithmetic walks
18
+ // linearly across whatever ordering the consumer last installed;
19
+ // there is no shadow shuffle queue + no shuffle-aware skip
20
+ // overloads.
21
+
22
+ /// `skipToIndex` is an explicit consumer intent — it always honours
23
+ /// the target index in every repeat mode, including `.queue` (it
24
+ /// never wraps — the target is absolute). Out-of-range indices
25
+ /// silently no-op.
26
+ func skipToIndex(index: Double) throws -> Promise<Void> {
27
+ return enqueue {
28
+ // Engine-aware: under CrossfadeEngine `self.player` is nil
29
+ // by design. Gating the skip on the engine surface lets
30
+ // mid-play engine swaps + crossfade-mode user skips work.
31
+ guard self.engine != nil else { return }
32
+ // See `InputGuards.validQueueIndex` for the rationale —
33
+ // validates non-finite + out-of-range in Double space so
34
+ // the subsequent Int conversion is bounded.
35
+ guard let target = InputGuards.validQueueIndex(
36
+ index, count: self.tracks.count
37
+ ) else { return }
38
+ // During cast, jump the receiver to the target index; its status
39
+ // echo drives the metadata + active-track update through the
40
+ // bridge (same as skipToNext/Previous). The local engine stays
41
+ // paused, so a local index write would desync from the receiver.
42
+ if CastTransportRouter.routeSkipToIndex(index: target) { return }
43
+ self.applyNewCurrentIndex(
44
+ newIndex: target, reason: .userSkipToIndex)
45
+ }
46
+ }
47
+
48
+ func skipToNext() throws -> Promise<Void> {
49
+ return enqueue {
50
+ if CastTransportRouter.routeSkipToNext() { return }
51
+ guard self.engine != nil else { return }
52
+ guard let next = self.computeNextIndex() else { return }
53
+ self.applyNewCurrentIndex(
54
+ newIndex: next, reason: .userSkipNext)
55
+ }
56
+ }
57
+
58
+ /// Skip-previous, resolved by `QueueSkipArithmetic.resolveSkipToPrevious`
59
+ /// from `PlayerConfig.skipToPreviousBehavior`. With the default `.previous`
60
+ /// this goes to the previous track if there is one (`.off`/`.track` don't
61
+ /// wrap, `.queue` wraps; `.track` navigates like `.off` and repeat-one
62
+ /// persists). With `.restartOrPrevious` it restarts the current track (seek
63
+ /// to 0) when past the threshold, otherwise goes to the previous track.
64
+ func skipToPrevious() throws -> Promise<Void> {
65
+ return enqueue {
66
+ if CastTransportRouter.routeSkipToPrevious() { return }
67
+ guard let engine = self.engine else { return }
68
+ let positionMs = Int(max(0, engine.currentPositionSeconds) * 1000)
69
+ let action = QueueSkipArithmetic.resolveSkipToPrevious(
70
+ trackCount: self.tracks.count,
71
+ currentIndex: self.currentTrackIndex,
72
+ repeatMode: self.repeatModeAsSkipMode(),
73
+ restartEnabled: self.skipRestartEnabled(),
74
+ positionMs: positionMs)
75
+ switch action {
76
+ case .previous(let idx):
77
+ self.applyNewCurrentIndex(newIndex: idx, reason: .userSkipPrevious)
78
+ case .restartCurrent:
79
+ // Restart the current track from 0 — a seek, not a track change.
80
+ // Same-index `applyNewCurrentIndex` takes its restart branch
81
+ // (currentItem unchanged → no `onTrackChange`), engine-aware across
82
+ // gapless (live / drained-rebuild) + crossfade.
83
+ self.applyNewCurrentIndex(
84
+ newIndex: self.currentTrackIndex, reason: .userSkipPrevious)
85
+ case .noOp:
86
+ return
87
+ }
88
+ }
89
+ }
90
+
91
+ /// Next-track index. Delegates to `QueueSkipArithmetic.computeNext`.
92
+ /// cur<0 sentinel: "nothing playing yet" → skipToNext is a no-op
93
+ /// (consumer starts via `setQueue` + `play` or `skipToIndex(0)`).
94
+ internal func computeNextIndex() -> Int? {
95
+ return QueueSkipArithmetic.computeNext(
96
+ trackCount: self.tracks.count,
97
+ currentIndex: self.currentTrackIndex,
98
+ repeatMode: repeatModeAsSkipMode())
99
+ }
100
+
101
+ /// Previous-track index. Delegates to `QueueSkipArithmetic.computePrevious`.
102
+ /// Same cur<0 early-bail as `computeNextIndex`.
103
+ internal func computePreviousIndex() -> Int? {
104
+ return QueueSkipArithmetic.computePrevious(
105
+ trackCount: self.tracks.count,
106
+ currentIndex: self.currentTrackIndex,
107
+ repeatMode: repeatModeAsSkipMode())
108
+ }
109
+
110
+ /// Whether skip-to-previous restarts the current track past the threshold
111
+ /// (`PlayerConfig.skipToPreviousBehavior == .restartOrPrevious`). Read live
112
+ /// from the current config at each skip + capability recompute.
113
+ private func skipRestartEnabled() -> Bool {
114
+ return self.config.skipToPreviousBehavior == .restartOrPrevious
115
+ }
116
+
117
+ /// Map the Nitrogen-generated `RepeatMode` enum onto the pure-Swift
118
+ /// `QueueSkipRepeatMode` so `QueueSkipArithmetic` can stay off the
119
+ /// Swift ↔ C++ interop path.
120
+ internal func repeatModeAsSkipMode() -> QueueSkipRepeatMode {
121
+ switch self.repeatModeState {
122
+ case .off: return .off
123
+ case .track: return .track
124
+ case .queue: return .queue
125
+ }
126
+ }
127
+
128
+ /// Recompute `cachedSkipCapability` from
129
+ /// authoritative state (`tracks`, `currentTrackIndex`,
130
+ /// `repeatModeState`) and fire `onSkipCapabilityChange` if either
131
+ /// flipped.
132
+ ///
133
+ /// **Threading invariant:** must be called on `playerQueue`. Reads
134
+ /// `tracks` / `currentTrackIndex` / `repeatModeState`, all of
135
+ /// which mutate only on that queue, so the read is race-free; the
136
+ /// listener fire iterates a snapshot under `ListenerRegistry`'s
137
+ /// internal lock so subscriber add/remove during the fire round
138
+ /// is safe.
139
+ ///
140
+ /// **Dedup:** the listener fires only when the (prev, next) tuple
141
+ /// actually changes. Two back-to-back recompute calls that land on
142
+ /// the same tuple emit once, not twice. This is what makes it safe
143
+ /// to scatter `recomputeCapabilities()` calls across every
144
+ /// mutation site without producing chatty events.
145
+ ///
146
+ /// **Callers** (every site that writes `tracks` /
147
+ /// `currentTrackIndex` / `repeatModeState`):
148
+ /// - `setQueue`, `addToQueueBody`, `removeFromQueueBody`,
149
+ /// `moveInQueueBody`, `clearQueue`
150
+ /// - `applyNewCurrentIndex` (covers skipToNext / skipToPrevious /
151
+ /// skipToIndex)
152
+ /// - `handleCurrentItemDidChange` (covers AVQueuePlayer auto-advance
153
+ /// that updates `currentTrackIndex` via `matchTrackIndex`)
154
+ /// - `setRepeatMode`, `shuffleQueue`
155
+ /// - `destroy` (resets to (false, false) so a final emit lets
156
+ /// subscribers know the player is gone)
157
+ internal func recomputeCapabilities() {
158
+ // Building the mirror maps the whole track list, so it is only built when
159
+ // a car is attached to receive it. The flag is read once and travels with
160
+ // the snapshot: a `nil` built because no car was attached must never be
161
+ // mistaken downstream for a `nil` that means "clear the mirror".
162
+ let carAttached = CarPlayCoordinator.shared.isCarConnected
163
+ let queueSnapshot = carAttached ? buildCarPlayQueueSnapshot() : nil
164
+ publishCapabilities(
165
+ computeCapabilities(), queueSnapshot: queueSnapshot, carAttached: carAttached)
166
+ }
167
+
168
+ /// Skip capability derived from `tracks` / `currentTrackIndex` /
169
+ /// `repeatModeState`. Pure — reads state and returns a value, no
170
+ /// listener fire and no remote-command or CarPlay write.
171
+ internal func computeCapabilities() -> SkipCapability {
172
+ let mode = repeatModeAsSkipMode()
173
+ let count = tracks.count
174
+ let cur = currentTrackIndex
175
+ return SkipCapability(
176
+ canSkipPrevious: QueueSkipArithmetic.canSkipPrevious(
177
+ trackCount: count, currentIndex: cur, repeatMode: mode,
178
+ restartEnabled: skipRestartEnabled()),
179
+ canSkipNext: QueueSkipArithmetic.canSkipNext(
180
+ trackCount: count, currentIndex: cur, repeatMode: mode))
181
+ }
182
+
183
+ /// Publish `capability` and `queueSnapshot`: refresh the CarPlay Up Next
184
+ /// mirror, then cache the pair, fire `onSkipCapabilityChange` and update the
185
+ /// remote-command centre when it actually changed. Runs on `playerQueue`;
186
+ /// `RemoteCommands` hands its own work to main, which owns
187
+ /// `MPRemoteCommandCenter`.
188
+ internal func publishCapabilities(
189
+ _ capability: SkipCapability, queueSnapshot: CarPlayQueueSnapshot?,
190
+ carAttached: Bool
191
+ ) {
192
+ // Every queue / current-index / repeat-mode change funnels through here,
193
+ // so it's the single point that keeps the CarPlay Up Next mirror current
194
+ // — done before the skip-capability dedup guard below so reorder/metadata
195
+ // changes (which don't flip skip capability) still refresh the mirror.
196
+ syncCarPlayQueueMirror(queueSnapshot, carAttached: carAttached)
197
+ let current = cachedSkipCapability
198
+ guard capability.canSkipPrevious != current.canSkipPrevious
199
+ || capability.canSkipNext != current.canSkipNext else {
200
+ return
201
+ }
202
+ cachedSkipCapability = capability
203
+ skipCapabilityListeners.forEach { $0(capability) }
204
+ self.remoteCommands.setSkipCapability(
205
+ canSkipNext: capability.canSkipNext,
206
+ canSkipPrevious: capability.canSkipPrevious
207
+ )
208
+ }
209
+
210
+ /// Mirror `snapshot` into the process-wide [CarPlayQueueProvider] so the
211
+ /// CarPlay Up Next surface renders the current playback queue without
212
+ /// round-tripping through JS; `nil` clears the mirror so the Up Next button
213
+ /// hides. `carAttached` is the caller's single read of the connectivity
214
+ /// flag, so the decision to build a snapshot and the decision to write one
215
+ /// cannot disagree. Driven from `publishCapabilities`, which every queue /
216
+ /// current-index / repeat-mode change funnels through, and from
217
+ /// `CarPlayCoordinator.onCarAttached`.
218
+ internal func syncCarPlayQueueMirror(
219
+ _ snapshot: CarPlayQueueSnapshot?, carAttached: Bool
220
+ ) {
221
+ // With no car attached there is nothing to receive the update, and the
222
+ // browse surface rebuilds from live state when one connects.
223
+ guard carAttached else { return }
224
+ // `CarPlayQueueProvider.setSnapshot` guards its store with a lock and
225
+ // posts its update notification on the main queue itself.
226
+ CarPlayCoordinator.shared.queueProvider.setSnapshot(snapshot)
227
+ }
228
+
229
+ /// Queue mirror for the CarPlay Up Next list, or `nil` for an empty queue
230
+ /// (which clears the mirror and hides the button). Pure — reads `tracks`
231
+ /// and `currentTrackIndex` and returns a value.
232
+ internal func buildCarPlayQueueSnapshot() -> CarPlayQueueSnapshot? {
233
+ guard !self.tracks.isEmpty else { return nil }
234
+ let entries = self.tracks.map { track in
235
+ CarPlayQueueEntry(
236
+ title: track.title ?? "",
237
+ subtitle: track.artist ?? track.album,
238
+ artworkUrl: track.artworkUrl
239
+ )
240
+ }
241
+ return CarPlayQueueSnapshot(
242
+ entries: entries, currentIndex: self.currentTrackIndex)
243
+ }
244
+
245
+ /// Apply a new `currentTrackIndex` and sync the AVQueuePlayer.
246
+ ///
247
+ /// **Forward skip** (newIndex > oldIndex) — surgical trim of items
248
+ /// between current and target + `player.advanceToNextItem()`. Safe
249
+ /// because `preferredForwardBufferDuration = 0.0` closes the
250
+ /// chain-advance-while-paused vector and the `didPlayToEndPending`
251
+ /// resync gate prevents any chain-advance from corrupting
252
+ /// `currentTrackIndex` even if it did fire.
253
+ ///
254
+ /// **Backward skip** (newIndex < oldIndex, including .queue-mode
255
+ /// wraparound) — full rebuild via `fullRebuildPlayerQueue`.
256
+ /// AVQueuePlayer is forward-only; backward navigation always
257
+ /// requires removing all items + re-inserting from the new index.
258
+ ///
259
+ /// - `newIndex == oldIndex` → restarts the current track from position 0
260
+ /// with no track-change event — seeking in place, or reinstalling the
261
+ /// track when it is not materialised in the engine.
262
+ /// - out-of-range `newIndex` → silent no-op.
263
+ ///
264
+ /// **Void return is intentional.** Silent-return cases here
265
+ /// (out-of-range, identity skip) are legitimate; track-change /
266
+ /// queue-end events flow through `handleCurrentItemDidChange`
267
+ /// driven by the AVQueuePlayer `\.currentItem` KVO, which is the
268
+ /// source of truth for whether a track actually became current.
269
+ /// A `Bool` return would be a *prediction* of whether KVO will
270
+ /// fire — predictions drift, KVO doesn't.
271
+ internal func applyNewCurrentIndex(
272
+ newIndex: Int, reason: TrackChangeReason
273
+ ) {
274
+ guard newIndex >= 0, newIndex < self.tracks.count else { return }
275
+
276
+ let oldIndex = self.currentTrackIndex
277
+ if newIndex == oldIndex {
278
+ // A same-index skip is an explicit "restart this track" — no longer at
279
+ // the end of the queue. Clear `reachedQueueEnd` here because this branch returns
280
+ // before `dispatchTrackChange` (which is the other clear point), so a
281
+ // crossfade in-place replay after end-of-queue would otherwise stay
282
+ // stuck on `.ended`.
283
+ self.reachedQueueEnd = false
284
+ // Same-index user skip = "restart this track from 0". Don't
285
+ // rebuild the queue or fire onTrackChange (the active track
286
+ // hasn't changed), but do reset the engine's playhead so the
287
+ // call-site contract is "track N from position 0" regardless
288
+ // of where the engine currently is — including after a
289
+ // setPlaybackMode swap that carried position across.
290
+ //
291
+ // Engine-aware: an engine indexes within the slice it was handed,
292
+ // never in the lib's queue-position space, so a queue position has
293
+ // to be translated by item identity before it can be seeked. For
294
+ // gapless the reset goes through the AVQueuePlayer's currentItem
295
+ // directly instead.
296
+ //
297
+ // A drained gapless AVQueuePlayer (`currentItem == nil`, so `items()` is
298
+ // empty) can't be seeked — `actionAtItemEnd = .advance` consumes items as
299
+ // they finish, so at end-of-queue there's nothing to seek. Rebuild at the
300
+ // current index to reload the track so the restart intent isn't dropped.
301
+ // Crossfade (`self.player == nil`) keeps its item on the leading leg, so
302
+ // the engine seek resets it in place.
303
+ if let player = self.player, player.currentItem != nil {
304
+ player.currentItem?.seek(to: .zero, completionHandler: nil)
305
+ } else if self.player != nil {
306
+ self.performingMutation { self.fullRebuildPlayerQueue() }
307
+ } else if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
308
+ self.engine?.seek(toIndex: engineIdx, position: 0)
309
+ } else {
310
+ // Target isn't materialised in the engine — reinstall at it.
311
+ self.performingMutation { self.fullRebuildPlayerQueue() }
312
+ }
313
+ return
314
+ }
315
+
316
+ // Stash reason only now that we know a real transition is
317
+ // happening. Consumed by `handleCurrentItemDidChange` on the
318
+ // next non-nil `\.currentItem` KVO fire. Mid-rebuild nil fires
319
+ // are suppressed by `isMutatingPlayerQueue` so the reason
320
+ // survives until the terminal currentItem.
321
+ self.pendingTrackChangeReason = reason
322
+ // Moving to another position is an intent to play it, so the queue is
323
+ // not over — cleared here rather than only on the track change that
324
+ // follows, so a target that cannot be built still reaches a verdict.
325
+ self.reachedQueueEnd = false
326
+
327
+ self.performingMutation {
328
+ if oldIndex < 0 {
329
+ // Queue was empty / uninitialised — populate player fresh.
330
+ self.currentTrackIndex = newIndex
331
+ fullRebuildPlayerQueue()
332
+ return
333
+ }
334
+
335
+ self.currentTrackIndex = newIndex
336
+
337
+ if self.player == nil {
338
+ // CrossfadeEngine seeks by index within its own queue, so translate
339
+ // the queue position by item identity first. Not found means the
340
+ // target isn't materialised — reinstall the queue at it rather than
341
+ // issuing a seek the engine would reject. The gapless forward-trim
342
+ // (`forwardSkipBy`) is not usable here: it assumes `allMediaItems`
343
+ // is the remaining forward-only slice, which is false for crossfade.
344
+ if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
345
+ self.engine?.seek(toIndex: engineIdx, position: 0)
346
+ // The seek's own track-change fires while this mutation is in
347
+ // flight, and the delegate skips the top-up for the duration; a
348
+ // skip onto the last installed position would otherwise leave no
349
+ // run ahead of it until the next transition, which a hard cut is.
350
+ self.topUpWindow()
351
+ } else {
352
+ // The target is not in the engine's run — a track appended after the
353
+ // queue ended, say. The rebuild installs it, but its surgical path
354
+ // extends the run behind the current item without re-seating the
355
+ // engine, so `currentTrackIndex` would advance while the engine kept
356
+ // playing the old item and no `onTrackChange` fired. Seek to the
357
+ // target once the run holds it so the skip actually moves the engine
358
+ // and the transition is announced.
359
+ fullRebuildPlayerQueue()
360
+ if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
361
+ self.engine?.seek(toIndex: engineIdx, position: 0)
362
+ self.topUpWindow()
363
+ }
364
+ }
365
+ } else if newIndex > oldIndex {
366
+ forwardSkipBy(delta: newIndex - oldIndex)
367
+ } else {
368
+ // Backward skip / `.queue`-mode wraparound — full rebuild.
369
+ // AVQueuePlayer is forward-only; backward navigation has no
370
+ // surgical alternative.
371
+ fullRebuildPlayerQueue()
372
+ }
373
+ }
374
+ // Every skip shifts the lookahead window. Reschedule covers all
375
+ // three skip methods (skipToIndex / Next / Previous all funnel
376
+ // through here).
377
+ self.rescheduleLookahead()
378
+ self.recomputeCapabilities()
379
+ }
380
+
381
+ /// Forward skip via surgical-trim + advanceToNextItem on the
382
+ /// player's existing items array. Caller holds the surrounding
383
+ /// `performingMutation` scope; this helper does NOT re-enter it.
384
+ /// Falls back to `fullRebuildPlayerQueue` when the player snapshot
385
+ /// is shorter than `delta` (defensive against partial chain-
386
+ /// advance / error-recovery state).
387
+ ///
388
+ /// Workaround: `advanceToNextItem` while paused can chain-advance
389
+ /// through unbuffered items because AVQueuePlayer interprets
390
+ /// unresolved durations as "played to end". Mitigated by
391
+ /// `preferredForwardBufferDuration = 0.0` at item construction +
392
+ /// the `didPlayToEndPending` resync gate.
393
+ /// TODO: monitor upstream for fixes — https://developer.apple.com/forums/thread/683744
394
+ private func forwardSkipBy(delta: Int) {
395
+ guard let engine = self.engine else { return }
396
+ let snapshot = engine.allMediaItems
397
+ guard snapshot.count > delta else {
398
+ fullRebuildPlayerQueue()
399
+ return
400
+ }
401
+ // Rearm the initial-buffer leniency window for the post-advance
402
+ // currentItem. `fullRebuildPlayerQueue` does this before
403
+ // inserting; surgical-trim doesn't touch items-array order, so
404
+ // the call is explicit here.
405
+ rearmGaplessFlip()
406
+ let trimRange = 1 ..< delta
407
+ let toRemove = Array(snapshot[trimRange])
408
+ for item in toRemove {
409
+ engine.remove(item)
410
+ }
411
+ engine.skipToNext()
412
+ // The trim consumed the run ahead of the playhead. A skip while paused
413
+ // never ticks the progress observer, so nothing else would refill it.
414
+ self.topUpWindow()
415
+ }
416
+
417
+ /// Reschedule the proactive prefetcher after a queue or skip
418
+ /// mutation. Reads `tracks` + `currentTrackIndex` and dispatches
419
+ /// the next N remote tracks to [LookaheadCachePrefetcher]. No-op
420
+ /// when the prefetcher hasn't been built (configure not yet
421
+ /// called).
422
+ internal func rescheduleLookahead() {
423
+ self.refreshCacheProtection()
424
+ guard let prefetcher = self.lookaheadCachePrefetcher else { return }
425
+ prefetcher.reschedule(
426
+ tracks: self.tracks,
427
+ currentIndex: self.currentTrackIndex
428
+ )
429
+ }
430
+
431
+ /// Tell the cache which files eviction must not take: the playing track,
432
+ /// the tracks being prefetched ahead of it, and one behind for an
433
+ /// immediate skip-back.
434
+ ///
435
+ /// `commitDownloadedFile` evicts after every prefetch commit, so without
436
+ /// this the entry backing the playing item can be deleted while it plays.
437
+ /// Bounded deliberately — protecting the whole remaining queue would leave
438
+ /// nothing evictable and turn each commit into a cache wipe.
439
+ private func refreshCacheProtection() {
440
+ guard let cache = self.lookaheadCache else { return }
441
+ let cur = self.currentTrackIndex
442
+ guard cur >= 0, cur < self.tracks.count else {
443
+ cache.setProtectedUrls([])
444
+ return
445
+ }
446
+ // The count crosses from JS as a Double, so Infinity and NaN are reachable
447
+ // and `Int(_:)` traps on both. Clamp before converting.
448
+ let ahead = min(
449
+ InputGuards.validLookaheadCount(self.lookaheadConfig.lookaheadCount),
450
+ self.tracks.count)
451
+ let lower = max(0, cur - 1)
452
+ let upper = min(self.tracks.count - 1, cur + ahead)
453
+ cache.setProtectedUrls(Set(self.tracks[lower ... upper].map { $0.url }))
454
+ }
455
+ }