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
@@ -0,0 +1,213 @@
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
+ /// The current position's `queueItemId` when its failure has been reported
109
+ /// and not yet recovered; nil otherwise.
110
+ ///
111
+ /// The position matters because the queue only holds where it is for *this*
112
+ /// track's failure. A report against a track the playhead has since left says
113
+ /// nothing about whether the current one can play.
114
+ ///
115
+ /// Nil when the failing item carried no `queueItemId` — nothing can be
116
+ /// correlated to a position then, so the queue behaves as it did before the
117
+ /// failure. `AVQueueBuilder.makeItem` always sets one; raw items do not.
118
+ internal var reportedFailureOnCurrentTrack: String? {
119
+ guard let currentId = self.queueItemIds[safe: self.currentTrackIndex],
120
+ self.reportedFailureQueueItemIds.contains(currentId) else { return nil }
121
+ return currentId
122
+ }
123
+
124
+ /// Retry the current item when the network comes back after an outage.
125
+ ///
126
+ /// The common real-world case is a tunnel, a lift, or airplane mode: the
127
+ /// retries burn while there is no connection, the item ends up failed, and
128
+ /// nothing tries again — the user has to notice and press play. This runs
129
+ /// recovery at the moment it can actually succeed.
130
+ ///
131
+ /// The guards are the whole design. Intent and interruption are checked as
132
+ /// everywhere else, and the retry budget is refunded for **that one track
133
+ /// only** rather than reset wholesale: a flapping connection would otherwise
134
+ /// hand out unlimited retries and defeat the budget entirely.
135
+ internal func handleNetworkRestored() {
136
+ guard self.currentTrackIndex >= 0,
137
+ self.currentTrackIndex < self.queueItemIds.count else { return }
138
+ let qid = self.queueItemIds[self.currentTrackIndex]
139
+ // The unrecovered-failure test is the error key naming *this* track, not
140
+ // the last reported state: by the time a network comes back the announced
141
+ // sequence has settled on `paused`, or on `ended` when the queue drained.
142
+ // The key is cleared by every recovery path, so it is set only while this
143
+ // track is still broken.
144
+ guard NetworkRecoveryPolicy.shouldRetry(
145
+ wantsToPlay: self.wantsToPlay,
146
+ isInterrupted: self.isInterrupted,
147
+ isInError: self.lastErrorQueueItemId == qid,
148
+ isTransient: self.lastErrorCode.map(PlaybackErrorMapping.isTransient) ?? false
149
+ ) else { return }
150
+ if (self.retryAttemptsRemaining[qid] ?? 0) == 0 {
151
+ self.retryAttemptsRemaining[qid] = 1
152
+ }
153
+ self.lastErrorQueueItemId = nil
154
+ self.lastErrorCode = nil
155
+ self.retryFailedItem(queueItemId: qid)
156
+ }
157
+
158
+ /// Try to recover from a transient failure by reinstalling the engine's
159
+ /// items at the current position. Called from the transient auto-retry
160
+ /// path in `dispatchErrorOrRetry` and from `handleNetworkRestored`.
161
+ ///
162
+ /// Implementation: `fullRebuildPlayerQueue(forcingReinstall: true)` with
163
+ /// the current index unchanged. The force is load-bearing: a failed item
164
+ /// keeps its `queueItemId`, so an identity comparison would find the
165
+ /// slice already correct and reinstall nothing. Fresh AVPlayerItems are
166
+ /// built through `AVQueueBuilder.buildPlayerItems` on the existing
167
+ /// `queueItemIds` (so dedup keys remain stable), then playback seeks
168
+ /// back to the captured position.
169
+ ///
170
+ /// Gated on the engine, not on `self.player`: the latter is the gapless
171
+ /// queue player and is nil under crossfade.
172
+ internal func retryFailedItem(queueItemId qid: String) {
173
+ // The attempt has arrived, so whatever fails next is a new failure rather
174
+ // than an echo of the one being retried.
175
+ if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
176
+ self.reportedFailureQueueItemIds.remove(qid)
177
+ guard let engine = self.engine else { return }
178
+ // Verify the failed item is still the current one. A subsequent
179
+ // user skip / queue mutation could have moved past it; in that
180
+ // case the retry is moot.
181
+ guard let idx = self.queueItemIds.firstIndex(of: qid),
182
+ idx == self.currentTrackIndex else { return }
183
+ // A retry is an explicit intent to play this track again, so it clears
184
+ // `reachedQueueEnd` exactly as a seek or a skip does. Without this, a
185
+ // queue whose only track failed to load has already drained, been called
186
+ // ended and had its engine parked — so the rebuild below seats an item
187
+ // into a queue that believes it is finished, and playback never resumes.
188
+ self.reachedQueueEnd = false
189
+ // Captured before the rebuild, which reloads the item from zero: a
190
+ // transient blip well into a long track must not restart it.
191
+ let resumePosition = engine.currentPositionSeconds
192
+ // Forced: this exists to replace an item that failed, and a failed item
193
+ // keeps its `queueItemId`, so an identity comparison would find the slice
194
+ // already correct and rebuild nothing.
195
+ self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
196
+ // No seek beats a seek to the wrong track: with a trail installed,
197
+ // engine-index 0 is a track already played rather than the failed one.
198
+ if resumePosition.isFinite, resumePosition > 0,
199
+ let engineIdx = self.engineIndex(forQueuePosition: idx) {
200
+ engine.seek(toIndex: engineIdx, position: resumePosition)
201
+ }
202
+ // Resume only what was actually running. A transient load failure can
203
+ // arrive while the user is paused, and the rebuild leaves the item seated
204
+ // at rate 0 either way, so the transport intent is what separates
205
+ // "recover playback" from "start playing something nobody asked for".
206
+ // Through the engine so the user's playback speed is restored (a raw
207
+ // AVPlayer.play() resets rate to 1.0).
208
+ // An auto-retry completing mid-interruption must not start audio.
209
+ if self.wantsToPlay, !self.isInterrupted, engine.timeControlStatus != .playing {
210
+ self.beginPlayback()
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,465 @@
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
+ // Seating a position is an attempt at it, so whatever was reported against
278
+ // it no longer describes what is about to play. This is the clear point for
279
+ // every skip, including the ones that seat without rebuilding — a crossfade
280
+ // skip onto a position the engine still holds, and a forward skip that
281
+ // trims the installed run — which would otherwise leave the report standing
282
+ // and the track silently wedged: no report, no run extension, no end
283
+ // verdict, until play().
284
+ if let seated = self.queueItemIds[safe: newIndex] {
285
+ self.reportedFailureQueueItemIds.remove(seated)
286
+ }
287
+ if newIndex == oldIndex {
288
+ // A same-index skip is an explicit "restart this track" — no longer at
289
+ // the end of the queue. Clear `reachedQueueEnd` here because this branch returns
290
+ // before `dispatchTrackChange` (which is the other clear point), so a
291
+ // crossfade in-place replay after end-of-queue would otherwise stay
292
+ // stuck on `.ended`.
293
+ self.reachedQueueEnd = false
294
+ // Same-index user skip = "restart this track from 0". Don't
295
+ // rebuild the queue or fire onTrackChange (the active track
296
+ // hasn't changed), but do reset the engine's playhead so the
297
+ // call-site contract is "track N from position 0" regardless
298
+ // of where the engine currently is — including after a
299
+ // setPlaybackMode swap that carried position across.
300
+ //
301
+ // Engine-aware: an engine indexes within the slice it was handed,
302
+ // never in the lib's queue-position space, so a queue position has
303
+ // to be translated by item identity before it can be seeked. For
304
+ // gapless the reset goes through the AVQueuePlayer's currentItem
305
+ // directly instead.
306
+ //
307
+ // A drained gapless AVQueuePlayer (`currentItem == nil`, so `items()` is
308
+ // empty) can't be seeked — `actionAtItemEnd = .advance` consumes items as
309
+ // they finish, so at end-of-queue there's nothing to seek. Rebuild at the
310
+ // current index to reload the track so the restart intent isn't dropped.
311
+ // Crossfade (`self.player == nil`) keeps its item on the leading leg, so
312
+ // the engine seek resets it in place.
313
+ if let player = self.player, player.currentItem != nil {
314
+ player.currentItem?.seek(to: .zero, completionHandler: nil)
315
+ } else if self.player != nil {
316
+ self.performingMutation { self.fullRebuildPlayerQueue() }
317
+ } else if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
318
+ self.engine?.seek(toIndex: engineIdx, position: 0)
319
+ } else {
320
+ // Target isn't materialised in the engine — reinstall at it.
321
+ self.performingMutation { self.fullRebuildPlayerQueue() }
322
+ }
323
+ return
324
+ }
325
+
326
+ // Stash reason only now that we know a real transition is
327
+ // happening. Consumed by `handleCurrentItemDidChange` on the
328
+ // next non-nil `\.currentItem` KVO fire. Mid-rebuild nil fires
329
+ // are suppressed by `isMutatingPlayerQueue` so the reason
330
+ // survives until the terminal currentItem.
331
+ self.pendingTrackChangeReason = reason
332
+ // Moving to another position is an intent to play it, so the queue is
333
+ // not over — cleared here rather than only on the track change that
334
+ // follows, so a target that cannot be built still reaches a verdict.
335
+ self.reachedQueueEnd = false
336
+
337
+ self.performingMutation {
338
+ if oldIndex < 0 {
339
+ // Queue was empty / uninitialised — populate player fresh.
340
+ self.currentTrackIndex = newIndex
341
+ fullRebuildPlayerQueue()
342
+ return
343
+ }
344
+
345
+ self.currentTrackIndex = newIndex
346
+
347
+ if self.player == nil {
348
+ // CrossfadeEngine seeks by index within its own queue, so translate
349
+ // the queue position by item identity first. Not found means the
350
+ // target isn't materialised — reinstall the queue at it rather than
351
+ // issuing a seek the engine would reject. The gapless forward-trim
352
+ // (`forwardSkipBy`) is not usable here: it assumes `allMediaItems`
353
+ // is the remaining forward-only slice, which is false for crossfade.
354
+ if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
355
+ self.engine?.seek(toIndex: engineIdx, position: 0)
356
+ // The seek's own track-change fires while this mutation is in
357
+ // flight, and the delegate skips the top-up for the duration; a
358
+ // skip onto the last installed position would otherwise leave no
359
+ // run ahead of it until the next transition, which a hard cut is.
360
+ self.topUpWindow()
361
+ } else {
362
+ // The target is not in the engine's run — a track appended after the
363
+ // queue ended, say. The rebuild installs it, but its surgical path
364
+ // extends the run behind the current item without re-seating the
365
+ // engine, so `currentTrackIndex` would advance while the engine kept
366
+ // playing the old item and no `onTrackChange` fired. Seek to the
367
+ // target once the run holds it so the skip actually moves the engine
368
+ // and the transition is announced.
369
+ fullRebuildPlayerQueue()
370
+ if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
371
+ self.engine?.seek(toIndex: engineIdx, position: 0)
372
+ self.topUpWindow()
373
+ }
374
+ }
375
+ } else if newIndex > oldIndex {
376
+ forwardSkipBy(delta: newIndex - oldIndex)
377
+ } else {
378
+ // Backward skip / `.queue`-mode wraparound — full rebuild.
379
+ // AVQueuePlayer is forward-only; backward navigation has no
380
+ // surgical alternative.
381
+ fullRebuildPlayerQueue()
382
+ }
383
+ }
384
+ // Every skip shifts the lookahead window. Reschedule covers all
385
+ // three skip methods (skipToIndex / Next / Previous all funnel
386
+ // through here).
387
+ self.rescheduleLookahead()
388
+ self.recomputeCapabilities()
389
+ }
390
+
391
+ /// Forward skip via surgical-trim + advanceToNextItem on the
392
+ /// player's existing items array. Caller holds the surrounding
393
+ /// `performingMutation` scope; this helper does NOT re-enter it.
394
+ /// Falls back to `fullRebuildPlayerQueue` when the player snapshot
395
+ /// is shorter than `delta` (defensive against partial chain-
396
+ /// advance / error-recovery state).
397
+ ///
398
+ /// Workaround: `advanceToNextItem` while paused can chain-advance
399
+ /// through unbuffered items because AVQueuePlayer interprets
400
+ /// unresolved durations as "played to end". Mitigated by
401
+ /// `preferredForwardBufferDuration = 0.0` at item construction +
402
+ /// the `didPlayToEndPending` resync gate.
403
+ /// TODO: monitor upstream for fixes — https://developer.apple.com/forums/thread/683744
404
+ private func forwardSkipBy(delta: Int) {
405
+ guard let engine = self.engine else { return }
406
+ let snapshot = engine.allMediaItems
407
+ guard snapshot.count > delta else {
408
+ fullRebuildPlayerQueue()
409
+ return
410
+ }
411
+ // Rearm the initial-buffer leniency window for the post-advance
412
+ // currentItem. `fullRebuildPlayerQueue` does this before
413
+ // inserting; surgical-trim doesn't touch items-array order, so
414
+ // the call is explicit here.
415
+ rearmGaplessFlip()
416
+ let trimRange = 1 ..< delta
417
+ let toRemove = Array(snapshot[trimRange])
418
+ for item in toRemove {
419
+ engine.remove(item)
420
+ }
421
+ engine.skipToNext()
422
+ // The trim consumed the run ahead of the playhead. A skip while paused
423
+ // never ticks the progress observer, so nothing else would refill it.
424
+ self.topUpWindow()
425
+ }
426
+
427
+ /// Reschedule the proactive prefetcher after a queue or skip
428
+ /// mutation. Reads `tracks` + `currentTrackIndex` and dispatches
429
+ /// the next N remote tracks to [LookaheadCachePrefetcher]. No-op
430
+ /// when the prefetcher hasn't been built (configure not yet
431
+ /// called).
432
+ internal func rescheduleLookahead() {
433
+ self.refreshCacheProtection()
434
+ guard let prefetcher = self.lookaheadCachePrefetcher else { return }
435
+ prefetcher.reschedule(
436
+ tracks: self.tracks,
437
+ currentIndex: self.currentTrackIndex
438
+ )
439
+ }
440
+
441
+ /// Tell the cache which files eviction must not take: the playing track,
442
+ /// the tracks being prefetched ahead of it, and one behind for an
443
+ /// immediate skip-back.
444
+ ///
445
+ /// `commitDownloadedFile` evicts after every prefetch commit, so without
446
+ /// this the entry backing the playing item can be deleted while it plays.
447
+ /// Bounded deliberately — protecting the whole remaining queue would leave
448
+ /// nothing evictable and turn each commit into a cache wipe.
449
+ private func refreshCacheProtection() {
450
+ guard let cache = self.lookaheadCache else { return }
451
+ let cur = self.currentTrackIndex
452
+ guard cur >= 0, cur < self.tracks.count else {
453
+ cache.setProtectedUrls([])
454
+ return
455
+ }
456
+ // The count crosses from JS as a Double, so Infinity and NaN are reachable
457
+ // and `Int(_:)` traps on both. Clamp before converting.
458
+ let ahead = min(
459
+ InputGuards.validLookaheadCount(self.lookaheadConfig.lookaheadCount),
460
+ self.tracks.count)
461
+ let lower = max(0, cur - 1)
462
+ let upper = min(self.tracks.count - 1, cur + ahead)
463
+ cache.setProtectedUrls(Set(self.tracks[lower ... upper].map { $0.url }))
464
+ }
465
+ }