react-native-queue-player 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (355) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +839 -271
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  58. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  59. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  60. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  61. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  109. package/app.plugin.js +17 -1
  110. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  111. package/ios/AVQueueBuilder.swift +120 -85
  112. package/ios/ArtworkLoader.swift +0 -7
  113. package/ios/ArtworkResolver.swift +22 -4
  114. package/ios/AssetReplayGainReader.swift +118 -0
  115. package/ios/AudioSession.swift +67 -16
  116. package/ios/AudioTapProvider.swift +284 -149
  117. package/ios/CarPlayBridge.swift +0 -12
  118. package/ios/CarPlayCoordinator.swift +18 -2
  119. package/ios/CarPlaySceneDelegate.swift +2 -2
  120. package/ios/Cast/AirPlayRouteState.swift +9 -8
  121. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  122. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  123. package/ios/Cast/Core/CastSession.swift +2 -2
  124. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  125. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  126. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  127. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  128. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  129. package/ios/CastManager.swift +1 -1
  130. package/ios/CrossfadeEngine.swift +660 -226
  131. package/ios/EQTap.swift +20 -17
  132. package/ios/Equalizer.swift +1 -1
  133. package/ios/FLACStreamInfo.swift +65 -0
  134. package/ios/GaplessEngine.swift +57 -30
  135. package/ios/InputGuards.swift +35 -5
  136. package/ios/LookaheadCache.swift +202 -58
  137. package/ios/LookaheadCachePrefetcher.swift +48 -36
  138. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  139. package/ios/NetworkRecoveryPolicy.swift +32 -0
  140. package/ios/NowPlayingFormatExtractor.swift +25 -18
  141. package/ios/NowPlayingInfo.swift +84 -48
  142. package/ios/OutputRouteMonitor.swift +16 -3
  143. package/ios/PendingBrowseRequests.swift +1 -1
  144. package/ios/PlaceholderArtwork.swift +9 -5
  145. package/ios/PlaybackEngine.swift +165 -76
  146. package/ios/PlaybackErrorMapping.swift +3 -3
  147. package/ios/PlaybackModeStateMachine.swift +2 -2
  148. package/ios/PlaybackNetworkMonitor.swift +73 -0
  149. package/ios/PlayerStateDerivation.swift +6 -6
  150. package/ios/QueueMutationArithmetic.swift +25 -0
  151. package/ios/QueueSkipArithmetic.swift +5 -3
  152. package/ios/QueueWindowArithmetic.swift +152 -0
  153. package/ios/ReadThroughServer.swift +283 -0
  154. package/ios/RemoteCommands.swift +44 -4
  155. package/ios/ReplayGainData.swift +22 -3
  156. package/ios/ReplayGainExtractor.swift +70 -27
  157. package/ios/Siri/VoiceDonation.swift +14 -9
  158. package/ios/StreamingBitrateProbe.swift +22 -68
  159. package/ios/SuppliedReplayGain.swift +83 -0
  160. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  161. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  162. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  163. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  164. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  165. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  166. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  167. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  168. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  169. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  170. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  171. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  172. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  173. package/ios/Tests/EngineSwapTests.swift +91 -0
  174. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  175. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  176. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  177. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  178. package/ios/Tests/InputGuardsTests.swift +41 -0
  179. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  180. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  181. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  182. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  183. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  184. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  185. package/ios/Tests/MutationDeferralTests.swift +220 -0
  186. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  187. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  188. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  189. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  190. package/ios/Tests/PlayerFixtures.swift +48 -0
  191. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  192. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  193. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  194. package/ios/Tests/QueueStateTests.swift +3 -1
  195. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  196. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  197. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  198. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  199. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  200. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  201. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  202. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  203. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  204. package/ios/Tests/SkipIndexTests.swift +32 -2
  205. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  206. package/ios/Tests/StallRecoveryTests.swift +97 -0
  207. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  208. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  209. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  210. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  211. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  212. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  213. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  214. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  215. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  216. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  217. package/ios/TrackPlayer+Automotive.swift +101 -0
  218. package/ios/TrackPlayer+Cache.swift +232 -0
  219. package/ios/TrackPlayer+Config.swift +291 -0
  220. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  221. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  222. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  223. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  224. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  225. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  226. package/ios/TrackPlayer+Queue.swift +940 -0
  227. package/ios/TrackPlayer+Recovery.swift +196 -0
  228. package/ios/TrackPlayer+Skip.swift +455 -0
  229. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  230. package/ios/TrackPlayer+State.swift +108 -0
  231. package/ios/TrackPlayer+Threading.swift +135 -0
  232. package/ios/TrackPlayer+Transport.swift +267 -0
  233. package/ios/TrackPlayer+Window.swift +195 -0
  234. package/ios/TrackPlayer.swift +305 -4619
  235. package/ios/Visualizer.swift +6 -5
  236. package/ios/tests-harness/Podfile +1 -1
  237. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  238. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  239. package/lib/module/hooks/useActiveTrack.js +29 -22
  240. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  241. package/lib/module/hooks/useCast.js +8 -26
  242. package/lib/module/hooks/useCast.js.map +1 -1
  243. package/lib/module/hooks/useEqualizer.js +19 -12
  244. package/lib/module/hooks/useEqualizer.js.map +1 -1
  245. package/lib/module/hooks/useLookaheadCache.js +3 -7
  246. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  247. package/lib/module/hooks/useQueue.js +66 -19
  248. package/lib/module/hooks/useQueue.js.map +1 -1
  249. package/lib/module/index.js +6 -3
  250. package/lib/module/index.js.map +1 -1
  251. package/lib/module/queueDelta.js +41 -0
  252. package/lib/module/queueDelta.js.map +1 -0
  253. package/lib/module/types.js +28 -5
  254. package/lib/module/types.js.map +1 -1
  255. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  256. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  257. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  258. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  259. package/lib/typescript/hooks/useCast.d.ts +6 -1
  260. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  261. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  262. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  263. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  265. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  266. package/lib/typescript/index.d.ts +2 -1
  267. package/lib/typescript/index.d.ts.map +1 -1
  268. package/lib/typescript/queueDelta.d.ts +10 -0
  269. package/lib/typescript/queueDelta.d.ts.map +1 -0
  270. package/lib/typescript/types.d.ts +168 -12
  271. package/lib/typescript/types.d.ts.map +1 -1
  272. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  273. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  274. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  275. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  276. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  277. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  278. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  279. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  280. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  281. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  283. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  284. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  285. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  313. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  314. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  315. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  316. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  317. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  318. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  319. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  320. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  321. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  322. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  323. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  324. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  325. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  326. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  327. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  328. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  329. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  330. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  331. package/package.json +8 -8
  332. package/src/TrackPlayer.nitro.ts +53 -27
  333. package/src/hooks/useActiveTrack.ts +29 -22
  334. package/src/hooks/useCast.ts +14 -15
  335. package/src/hooks/useEqualizer.ts +19 -12
  336. package/src/hooks/useLookaheadCache.ts +3 -7
  337. package/src/hooks/useQueue.ts +66 -17
  338. package/src/index.ts +13 -3
  339. package/src/queueDelta.ts +41 -0
  340. package/src/types.ts +169 -12
  341. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  342. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  343. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  344. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  345. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  346. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  347. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  348. package/ios/MetadataReader.swift +0 -555
  349. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  350. package/ios/Tests/MetadataReaderTests.swift +0 -603
  351. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  352. package/ios/Tests/VoiceDonationTests.swift +0 -26
  353. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  354. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  355. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,303 @@
1
+ import AVFoundation
2
+ import XCTest
3
+ @testable import QueuePlayer
4
+
5
+ /// The automatic retry rebuilds the failed item and resumes where playback had
6
+ /// reached, on whichever engine is active.
7
+ final class RetryRecoveryTests: XCTestCase {
8
+
9
+ /// One automatic attempt by default. A transient failure that survives a
10
+ /// rebuild is rarely cured by a second, and further attempts only delay the
11
+ /// error the consumer needs to act on; `retry()` is the escape hatch after it.
12
+ func testTheAutomaticRetryBudgetIsOneAttemptByDefault() {
13
+ let player = TrackPlayer()
14
+ XCTAssertEqual(player.effectiveAutoRetries(), 1)
15
+ }
16
+
17
+ func testTheConsumerCanAskForNoneOrMore() {
18
+ let player = TrackPlayer()
19
+ player.config = PlayerFixtures.config(autoRetries: 0)
20
+ XCTAssertEqual(player.effectiveAutoRetries(), 0, "zero means zero")
21
+ player.config = PlayerFixtures.config(autoRetries: 4)
22
+ XCTAssertEqual(player.effectiveAutoRetries(), 4)
23
+ player.config = PlayerFixtures.config(autoRetries: 99)
24
+ XCTAssertEqual(player.effectiveAutoRetries(), 5, "clamped to the documented ceiling")
25
+ player.config = PlayerFixtures.config(autoRetries: .nan)
26
+ XCTAssertEqual(player.effectiveAutoRetries(), 1, "a value that is not a number falls back")
27
+ }
28
+
29
+ /// A crossfade failure reaches the automatic retry.
30
+ ///
31
+ /// The retry is keyed by `queueItemId`, so an engine reporting a failure
32
+ /// without the item leaves `dispatchErrorOrRetry` synthesising a key that
33
+ /// cannot appear in the queue — the retry branch is skipped and a transient
34
+ /// blip goes straight to the consumer instead of being recovered.
35
+ func testAnEngineFailureCarryingItsItemIsRetried() {
36
+ let player = TrackPlayer()
37
+ let engine = RetryEngineStub()
38
+ seed(player, engine: engine)
39
+ let qid = player.queueItemIds[0]
40
+ player.retryAttemptsRemaining[qid] = 1
41
+ // The engine names the item that failed, carrying the queue identity the
42
+ // retry is keyed by.
43
+ let item = AVPlayerItem(url: URL(string: "https://example.com/0.m4a")!)
44
+ item.queueItemId = qid
45
+
46
+ player.engineDidFailWithError(
47
+ engine, error: URLError(.timedOut), item: item)
48
+
49
+ XCTAssertEqual(
50
+ player.retryAttemptsRemaining[qid], 0,
51
+ "a transient failure spends the track's automatic attempt")
52
+ }
53
+
54
+ /// The other half of the same contract: with no item there is nothing to key
55
+ /// a retry against, so the failure is surfaced rather than silently retried.
56
+ func testAFailureWithoutAnItemIsNotRetried() {
57
+ let player = TrackPlayer()
58
+ let engine = RetryEngineStub()
59
+ seed(player, engine: engine)
60
+ let qid = player.queueItemIds[0]
61
+ player.retryAttemptsRemaining[qid] = 1
62
+
63
+ player.engineDidFailWithError(
64
+ engine, error: URLError(.timedOut), item: nil)
65
+
66
+ XCTAssertEqual(
67
+ player.retryAttemptsRemaining[qid], 1,
68
+ "no item means no automatic attempt is spent")
69
+ }
70
+
71
+ /// A repeat of the same failure while the attempt is pending stays inside
72
+ /// the library.
73
+ func testAnEchoOfThePendingFailureIsNotReported() {
74
+ let player = TrackPlayer()
75
+ let engine = RetryEngineStub()
76
+ seed(player, engine: engine)
77
+ let qid = player.queueItemIds[0]
78
+ player.retryAttemptsRemaining[qid] = 1
79
+ let item = AVPlayerItem(url: URL(string: "https://example.com/0.m4a")!)
80
+ item.queueItemId = qid
81
+ var reported: [PlaybackError] = []
82
+ _ = player.errorListeners.add { reported.append($0) }
83
+
84
+ player.engineDidFailWithError(engine, error: URLError(.timedOut), item: item)
85
+ player.engineDidFailWithError(engine, error: URLError(.timedOut), item: item)
86
+
87
+ XCTAssertEqual(reported.count, 0, "the pending attempt owns both fires")
88
+ XCTAssertEqual(
89
+ player.retryAttemptsRemaining[qid], 0,
90
+ "one failure spends one attempt, however many times it is reported")
91
+ }
92
+
93
+ /// A failure after the attempt has run is the consumer's to see.
94
+ func testAFailureAfterTheAttemptIsReported() {
95
+ let player = TrackPlayer()
96
+ let engine = RetryEngineStub()
97
+ seed(player, engine: engine)
98
+ let qid = player.queueItemIds[0]
99
+ player.retryAttemptsRemaining[qid] = 1
100
+ let item = AVPlayerItem(url: URL(string: "https://example.com/0.m4a")!)
101
+ item.queueItemId = qid
102
+ var reported: [PlaybackError] = []
103
+ _ = player.errorListeners.add { reported.append($0) }
104
+
105
+ player.engineDidFailWithError(engine, error: URLError(.timedOut), item: item)
106
+ player.retryFailedItem(queueItemId: qid)
107
+ player.engineDidFailWithError(engine, error: URLError(.timedOut), item: item)
108
+
109
+ XCTAssertEqual(reported.count, 1, "the attempt was spent and the track is still broken")
110
+ }
111
+
112
+ /// A user retry hands back the allowance the consumer configured, so one for
113
+ /// a default player and none for a consumer who turned the automatic attempt
114
+ /// off.
115
+ func testAUserRetryRestoresTheConfiguredBudget() async throws {
116
+ let player = TrackPlayer()
117
+ let engine = RetryEngineStub()
118
+ seed(player, engine: engine)
119
+ player.config = PlayerFixtures.config(autoRetries: 0)
120
+ let qid = player.queueItemIds[0]
121
+
122
+ _ = try await player.retry().await()
123
+
124
+ XCTAssertEqual(
125
+ player.retryAttemptsRemaining[qid], 0,
126
+ "a consumer who asked for no automatic attempt must not be given one")
127
+ }
128
+
129
+ /// The user-facing retry clears `reachedQueueEnd` too — a queue whose
130
+ /// only track failed to load has already drained, and that is the case the
131
+ /// button exists for.
132
+ func testAUserRetryClearsTheEndOfQueueState() async throws {
133
+ let player = TrackPlayer()
134
+ let engine = RetryEngineStub()
135
+ seed(player, engine: engine)
136
+ player.reachedQueueEnd = true
137
+
138
+ _ = try await player.retry().await()
139
+
140
+ XCTAssertFalse(player.reachedQueueEnd)
141
+ }
142
+
143
+ /// The rebuild a user asked for replaces a scheduled one, so the rebuilt
144
+ /// item's own failure is reported rather than taken for an echo.
145
+ func testAUserRetrySupersedesAScheduledAttempt() async throws {
146
+ let player = TrackPlayer()
147
+ let engine = RetryEngineStub()
148
+ seed(player, engine: engine)
149
+ player.pendingRetryQueueItemId = player.queueItemIds[0]
150
+
151
+ _ = try await player.retry().await()
152
+
153
+ XCTAssertNil(player.pendingRetryQueueItemId)
154
+ }
155
+
156
+ /// A retry clears `reachedQueueEnd`.
157
+ ///
158
+ /// A queue whose only track fails to load drains the engine, so the queue-end
159
+ /// verdict calls it finished and parks the player. Without clearing it
160
+ /// the retry seats an item into a queue that believes it is over, and
161
+ /// playback never resumes — the automatic attempt rebuilds and nothing plays.
162
+ func testRetryingClearsTheEndOfQueueState() {
163
+ let player = TrackPlayer()
164
+ let engine = RetryEngineStub()
165
+ seed(player, engine: engine)
166
+ player.reachedQueueEnd = true
167
+
168
+ player.retryFailedItem(queueItemId: player.queueItemIds[0])
169
+
170
+ XCTAssertFalse(
171
+ player.reachedQueueEnd,
172
+ "a retry is an intent to play this track, so the queue is not over")
173
+ }
174
+
175
+ /// A drain whose current item failed with an automatic attempt already
176
+ /// scheduled is not the end of the queue. `AVQueuePlayer` consumes a failed
177
+ /// item and drains to `currentItem == nil`, which reaches `applyEndVerdict`;
178
+ /// deciding a queue-end there clears the play intent the scheduled retry
179
+ /// reads, so the recovered track never resumes. The verdict holds the queue
180
+ /// open while a retry is pending for the current track.
181
+ func testADrainWithAPendingRetryDoesNotEndTheQueue() {
182
+ let player = TrackPlayer()
183
+ let engine = RetryEngineStub()
184
+ seed(player, engine: engine)
185
+ player.currentTrackIndex = 1
186
+ player.wantsToPlay = true
187
+ player.pendingRetryQueueItemId = player.queueItemIds[1]
188
+
189
+ player.applyEndVerdict()
190
+
191
+ XCTAssertTrue(
192
+ player.wantsToPlay,
193
+ "a pending retry keeps the play intent so the automatic attempt can resume")
194
+ XCTAssertFalse(
195
+ player.reachedQueueEnd,
196
+ "a drain with a retry pending is a recovery in flight, not the end of the queue")
197
+ }
198
+
199
+ func testRetryResumesFromWherePlaybackHadReached() {
200
+ let player = TrackPlayer()
201
+ let engine = RetryEngineStub()
202
+ engine.position = 42.5
203
+ seed(player, engine: engine)
204
+ // The crossfade shape: an engine is live, the gapless queue player is not.
205
+ XCTAssertNil(player.player)
206
+
207
+ player.retryFailedItem(queueItemId: player.queueItemIds[0])
208
+
209
+ XCTAssertEqual(
210
+ engine.seeks.first?.position, 42.5,
211
+ "a blip well into a long track must not restart it from zero")
212
+ }
213
+
214
+ func testRetryDoesNotSeekWhenPlaybackHadNotStarted() {
215
+ let player = TrackPlayer()
216
+ let engine = RetryEngineStub()
217
+ engine.position = 0
218
+ seed(player, engine: engine)
219
+
220
+ player.retryFailedItem(queueItemId: player.queueItemIds[0])
221
+
222
+ XCTAssertTrue(engine.seeks.isEmpty, "position 0 is the rebuild's own starting point")
223
+ }
224
+
225
+ func testRetryIsMootOnceTheUserHasMovedOn() {
226
+ let player = TrackPlayer()
227
+ let engine = RetryEngineStub()
228
+ engine.position = 30
229
+ seed(player, engine: engine)
230
+ let staleId = player.queueItemIds[1]
231
+ // The failure belongs to a track the player is no longer on.
232
+ player.retryFailedItem(queueItemId: staleId)
233
+
234
+ XCTAssertTrue(engine.seeks.isEmpty)
235
+ }
236
+
237
+ private func seed(_ player: TrackPlayer, engine: RetryEngineStub) {
238
+ _ = player.queueState.replaceAll((0 ..< 2).map {
239
+ TrackItem(
240
+ id: "t\($0)", url: "https://example.com/\($0).m4a", title: nil, artist: nil,
241
+ album: nil, duration: nil, artworkUrl: nil, extras: nil,
242
+ replayGainTrackGain: nil, replayGainTrackPeak: nil,
243
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil)
244
+ })
245
+ player.currentTrackIndex = 0
246
+ player.engine = engine
247
+ }
248
+ }
249
+
250
+ /// Records the seeks the retry issues; everything else is inert.
251
+ private final class RetryEngineStub: PlaybackEngine {
252
+ var position: TimeInterval = 0
253
+ private(set) var seeks: [(index: Int, position: TimeInterval)] = []
254
+
255
+ var currentPositionSeconds: TimeInterval { position }
256
+ func seek(toIndex index: Int, position: TimeInterval) {
257
+ seeks.append((index, position))
258
+ }
259
+
260
+ func setAudioMixProvider(_ provider: AudioMixProvider?) {}
261
+ func refreshAllItemMixes() {}
262
+ func release() {}
263
+ /// Holds what it is given, so a caller resolving a queue position to an
264
+ /// engine index by `queueItemId` gets the same answer a real engine would.
265
+ func setItems(_ items: [AVPlayerItem], startIndex: Int, startPositionSeconds: TimeInterval) {
266
+ installed = items
267
+ }
268
+ @discardableResult
269
+ func insertItems(_ items: [AVPlayerItem], after: AVPlayerItem?) -> Bool {
270
+ installed.append(contentsOf: items)
271
+ return true
272
+ }
273
+ func remove(_ item: AVPlayerItem) {
274
+ installed.removeAll { $0 === item }
275
+ }
276
+ func removeAllItems() {}
277
+ func canInsert(_ item: AVPlayerItem, after: AVPlayerItem?) -> Bool { true }
278
+ func play() {}
279
+ func pause() {}
280
+ func stop() {}
281
+ func skipToNext() {}
282
+ func skipToPrevious() {}
283
+ func setVolume(_ volume: Float) {}
284
+ func setPlaybackSpeed(_ speed: Float) {}
285
+ func seedPlaybackSpeed(_ speed: Float) {}
286
+ func setRepeatMode(_ mode: RepeatMode) {}
287
+ func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {}
288
+
289
+ var currentDurationSeconds: TimeInterval { 0 }
290
+ var isPlaying: Bool { false }
291
+ var currentMediaItemIndex: Int { -1 }
292
+ var bufferedPositionSeconds: TimeInterval { 0 }
293
+ var timeControlStatus: AVPlayer.TimeControlStatus { .paused }
294
+ var currentMediaItem: AVPlayerItem? { nil }
295
+ private var installed: [AVPlayerItem] = []
296
+ var allMediaItems: [AVPlayerItem] { installed }
297
+ /// No fade to schedule — this stub exercises the rest of the surface.
298
+ var isFadePendingOrActive: Bool { false }
299
+ /// Nothing attached — this stub exercises the rest of the surface.
300
+ var attachedQueueItemIds: [String] { [] }
301
+
302
+ var delegate: PlaybackEngineDelegate?
303
+ }
@@ -34,20 +34,50 @@ final class SkipCapabilityTests: XCTestCase {
34
34
  dispose()
35
35
  }
36
36
 
37
- func testDisposeBeforeAutoFireSuppressesCallback() throws {
38
- // Subscribing then immediately disposing produces no callback,
39
- // even though the auto-fire is dispatched asynchronously to main.
37
+ /// A disposed subscription receives nothing once the auto-fire has settled.
38
+ ///
39
+ /// An immediate dispose racing the auto-fire is a race, not a contract: the
40
+ /// fire is enqueued on the owner queue when `subscribe` returns, so the queue
41
+ /// block usually waits while the caller's synchronous dispose lands first,
42
+ /// and under load it does not. What the library guarantees is that the
43
+ /// registry is consulted before the callback runs, so a settled dispose is
44
+ /// final.
45
+ func testDisposeStopsCallbacksOnceSettled() throws {
40
46
  let player = TrackPlayer()
41
47
  var fireCount = 0
42
48
  let dispose = try player.onSkipCapabilityChange { _ in
43
49
  fireCount += 1
44
50
  }
45
51
  dispose()
46
- // Pump the run loop so the deferred main.async block executes.
47
- let exp = expectation(description: "drain main")
48
- DispatchQueue.main.async { exp.fulfill() }
49
- wait(for: [exp], timeout: 1.0)
50
- XCTAssertEqual(fireCount, 0, "Auto-fire must not fire after dispose.")
52
+ drain(player)
53
+ let afterDispose = fireCount
54
+
55
+ // A queue with a track on either side, so the recompute below is a real
56
+ // change from the empty-queue capability rather than a deduplicated repeat
57
+ // — the disposed subscription has to be what keeps it from arriving.
58
+ player.playerQueue.async {
59
+ _ = player.queueState.replaceAll((0 ..< 3).map {
60
+ PlayerFixtures.track(id: "t\($0)", url: "https://example.com/\($0).m4a")
61
+ })
62
+ player.currentTrackIndex = 1
63
+ player.recomputeCapabilities()
64
+ }
65
+ drain(player)
66
+
67
+ XCTAssertEqual(
68
+ fireCount, afterDispose,
69
+ "a disposed subscription receives no further capability changes")
70
+ }
71
+
72
+ /// Two hops: the auto-fire runs on the owner queue and consumers are called
73
+ /// from there, so draining main alone can return before either has run.
74
+ private func drain(_ player: TrackPlayer) {
75
+ let queueDrained = expectation(description: "owner queue drained")
76
+ player.playerQueue.async { queueDrained.fulfill() }
77
+ wait(for: [queueDrained], timeout: 2.0)
78
+ let mainDrained = expectation(description: "main drained")
79
+ DispatchQueue.main.async { mainDrained.fulfill() }
80
+ wait(for: [mainDrained], timeout: 2.0)
51
81
  }
52
82
 
53
83
  func testOnSkipCapabilityChangeMultiSubscriberEachGetsAutoFire() throws {
@@ -81,6 +111,129 @@ final class SkipCapabilityTests: XCTestCase {
81
111
  dispose()
82
112
  }
83
113
 
114
+ // MARK: - computeCapabilities
115
+
116
+ func testComputeCapabilitiesEnablesBothDirectionsMidQueue() {
117
+ let player = TrackPlayer()
118
+ seedQueue(player, count: 3, currentIndex: 1)
119
+ let capability = player.computeCapabilities()
120
+ XCTAssertTrue(capability.canSkipPrevious)
121
+ XCTAssertTrue(capability.canSkipNext)
122
+ }
123
+
124
+ func testComputeCapabilitiesDisablesTheAbsentDirectionAtEachQueueEnd() {
125
+ let player = TrackPlayer()
126
+ seedQueue(player, count: 3, currentIndex: 0)
127
+ let first = player.computeCapabilities()
128
+ XCTAssertFalse(first.canSkipPrevious)
129
+ XCTAssertTrue(first.canSkipNext)
130
+
131
+ player.currentTrackIndex = 2
132
+ let last = player.computeCapabilities()
133
+ XCTAssertTrue(last.canSkipPrevious)
134
+ XCTAssertFalse(last.canSkipNext)
135
+ }
136
+
137
+ func testComputeCapabilitiesEnablesBothDirectionsUnderRepeatQueue() {
138
+ let player = TrackPlayer()
139
+ seedQueue(player, count: 3, currentIndex: 0)
140
+ player.repeatModeState = .queue
141
+ let capability = player.computeCapabilities()
142
+ XCTAssertTrue(capability.canSkipPrevious)
143
+ XCTAssertTrue(capability.canSkipNext)
144
+ }
145
+
146
+ func testComputeCapabilitiesLeavesTheCachedCapabilityUntouched() {
147
+ let player = TrackPlayer()
148
+ seedQueue(player, count: 3, currentIndex: 1)
149
+ player.cachedSkipCapability = SkipCapability(canSkipPrevious: true, canSkipNext: false)
150
+
151
+ _ = player.computeCapabilities()
152
+
153
+ XCTAssertTrue(player.cachedSkipCapability.canSkipPrevious)
154
+ XCTAssertFalse(player.cachedSkipCapability.canSkipNext)
155
+ }
156
+
157
+ // MARK: - buildCarPlayQueueSnapshot
158
+
159
+ func testBuildCarPlayQueueSnapshotMirrorsTheQueueAndCurrentIndex() {
160
+ let player = TrackPlayer()
161
+ seedQueue(player, count: 3, currentIndex: 2)
162
+ let snapshot = player.buildCarPlayQueueSnapshot()
163
+ XCTAssertEqual(snapshot?.entries.count, 3)
164
+ XCTAssertEqual(snapshot?.currentIndex, 2)
165
+ XCTAssertEqual(snapshot?.entries.map { $0.title }, ["Track 0", "Track 1", "Track 2"])
166
+ }
167
+
168
+ func testBuildCarPlayQueueSnapshotIsNilForAnEmptyQueue() {
169
+ let player = TrackPlayer()
170
+ XCTAssertNil(player.buildCarPlayQueueSnapshot())
171
+ }
172
+
173
+ func testBuildCarPlayQueueSnapshotFallsBackToAlbumWhenArtistIsAbsent() {
174
+ let player = TrackPlayer()
175
+ _ = player.queueState.replaceAll([
176
+ makeTrack(index: 0, title: nil, artist: nil, album: "Album"),
177
+ makeTrack(index: 1, title: "Titled", artist: "Artist", album: "Album"),
178
+ ])
179
+ player.currentTrackIndex = 0
180
+ let entries = player.buildCarPlayQueueSnapshot()?.entries
181
+ XCTAssertEqual(entries?[0].title, "", "an absent title renders as an empty string")
182
+ XCTAssertEqual(entries?[0].subtitle, "Album")
183
+ XCTAssertEqual(entries?[1].subtitle, "Artist")
184
+ }
185
+
186
+ // MARK: - publishCapabilities
187
+
188
+ func testPublishCapabilitiesRefreshesTheCarPlayMirrorWhenTheCapabilityIsUnchanged() {
189
+ let player = TrackPlayer()
190
+ seedQueue(player, count: 3, currentIndex: 1)
191
+ let capability = player.computeCapabilities()
192
+ player.cachedSkipCapability = capability
193
+ defer { CarPlayCoordinator.shared.queueProvider.setSnapshot(nil) }
194
+
195
+ player.publishCapabilities(
196
+ capability, queueSnapshot: player.buildCarPlayQueueSnapshot(), carAttached: true)
197
+
198
+ XCTAssertEqual(
199
+ CarPlayCoordinator.shared.queueProvider.getSnapshot()?.entries.count, 3,
200
+ "a reorder or metadata change refreshes the mirror even though it leaves "
201
+ + "skip capability untouched")
202
+ }
203
+
204
+ func testPublishCapabilitiesClearsTheCarPlayMirrorForAnEmptyQueue() {
205
+ let player = TrackPlayer()
206
+ defer { CarPlayCoordinator.shared.queueProvider.setSnapshot(nil) }
207
+ CarPlayCoordinator.shared.queueProvider.setSnapshot(
208
+ CarPlayQueueSnapshot(
209
+ entries: [CarPlayQueueEntry(title: "stale", subtitle: nil, artworkUrl: nil)],
210
+ currentIndex: 0))
211
+
212
+ player.publishCapabilities(
213
+ player.computeCapabilities(),
214
+ queueSnapshot: player.buildCarPlayQueueSnapshot(), carAttached: true)
215
+
216
+ XCTAssertNil(CarPlayCoordinator.shared.queueProvider.getSnapshot())
217
+ }
218
+
219
+ func testPublishCapabilitiesLeavesTheMirrorAloneWithNoCarAttached() {
220
+ let player = TrackPlayer()
221
+ seedQueue(player, count: 2, currentIndex: 0)
222
+ defer { CarPlayCoordinator.shared.queueProvider.setSnapshot(nil) }
223
+ CarPlayCoordinator.shared.queueProvider.setSnapshot(
224
+ CarPlayQueueSnapshot(
225
+ entries: [CarPlayQueueEntry(title: "held", subtitle: nil, artworkUrl: nil)],
226
+ currentIndex: 0))
227
+
228
+ player.publishCapabilities(
229
+ player.computeCapabilities(), queueSnapshot: nil, carAttached: false)
230
+
231
+ XCTAssertEqual(
232
+ CarPlayCoordinator.shared.queueProvider.getSnapshot()?.entries.first?.title, "held",
233
+ "a snapshot that was never built because no car is attached must not be "
234
+ + "mistaken for a request to clear")
235
+ }
236
+
84
237
  func testListenerRegistryContainsAfterAddAndAbsentAfterRemove() {
85
238
  // After `add()` returns an id, `contains(id:)` returns true.
86
239
  // After `remove(id:)`, `contains(id:)` returns false.
@@ -92,4 +245,76 @@ final class SkipCapabilityTests: XCTestCase {
92
245
  XCTAssertFalse(registry.contains(id: id))
93
246
  XCTAssertFalse(registry.remove(id: id))
94
247
  }
248
+ private func seedQueue(_ player: TrackPlayer, count: Int, currentIndex: Int) {
249
+ _ = player.queueState.replaceAll(
250
+ (0 ..< count).map { makeTrack(index: $0, title: "Track \($0)") })
251
+ player.currentTrackIndex = currentIndex
252
+ }
253
+
254
+ private func makeTrack(
255
+ index: Int, title: String?, artist: String? = nil, album: String? = nil
256
+ ) -> TrackItem {
257
+ return TrackItem(
258
+ id: "t\(index)", url: "https://example.com/\(index).m4a",
259
+ title: title, artist: artist, album: album, duration: nil, artworkUrl: nil,
260
+ extras: nil, replayGainTrackGain: nil, replayGainTrackPeak: nil,
261
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil
262
+ )
263
+ }
264
+
265
+
266
+ /// Restart-or-previous with no loaded track keeps Previous disabled.
267
+ func testRestartModeWithNoLoadedTrackKeepsPreviousDisabled() {
268
+ let player = TrackPlayer()
269
+ player.config = PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, voiceVocabularyDonationEnabled: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: .restartOrPrevious, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
270
+ seedQueue(player, count: 3, currentIndex: -1)
271
+ XCTAssertFalse(player.computeCapabilities().canSkipPrevious)
272
+ }
273
+
274
+ /// Restart-or-previous with a loaded track at index 0 enables Previous.
275
+ func testRestartModeAtIndexZeroEnablesPrevious() {
276
+ let player = TrackPlayer()
277
+ player.config = PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, voiceVocabularyDonationEnabled: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: .restartOrPrevious, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
278
+ seedQueue(player, count: 3, currentIndex: 0)
279
+ XCTAssertTrue(player.computeCapabilities().canSkipPrevious)
280
+ }
281
+
282
+ // MARK: - cast index writes keep capability and source current
283
+
284
+ /// A receiver advance to the last track disables Next, as a local advance does.
285
+ func testRemoteTrackChangeToLastIndexDisablesNext() {
286
+ let player = TrackPlayer()
287
+ seedQueue(player, count: 3, currentIndex: 0)
288
+ player.playerQueue.async { player.recomputeCapabilities() }
289
+ drain(player)
290
+ XCTAssertTrue(player.cachedSkipCapability.canSkipNext)
291
+
292
+ player.emitRemoteTrackChange(index: 2)
293
+ drain(player)
294
+ XCTAssertEqual(player.currentTrackIndex, 2)
295
+ XCTAssertFalse(player.cachedSkipCapability.canSkipNext)
296
+ XCTAssertTrue(player.cachedSkipCapability.canSkipPrevious)
297
+ }
298
+
299
+ /// A receiver advance classifies the source of the track it moved to.
300
+ func testRemoteTrackChangeClassifiesTheNewTracksSource() {
301
+ let player = TrackPlayer()
302
+ _ = player.queueState.replaceAll([
303
+ makeTrack(index: 0, title: "a"),
304
+ makeTrack(index: 1, title: "b"),
305
+ TrackItem(
306
+ id: "local", url: "file:///tmp/local.m4a",
307
+ title: nil, artist: nil, album: nil, duration: nil, artworkUrl: nil,
308
+ extras: nil, replayGainTrackGain: nil, replayGainTrackPeak: nil,
309
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil),
310
+ ])
311
+ player.currentTrackIndex = 0
312
+ player.playerQueue.async { player.reclassifyCurrentTrackSource() }
313
+ drain(player)
314
+ XCTAssertEqual(player.currentTrackSource, .streaming)
315
+
316
+ player.emitRemoteTrackChange(index: 2)
317
+ drain(player)
318
+ XCTAssertEqual(player.currentTrackSource, .local)
319
+ }
95
320
  }
@@ -91,8 +91,8 @@ final class SkipIndexTests: XCTestCase {
91
91
 
92
92
  func testComputePreviousRepeatOffAtStartReturnsNil() {
93
93
  // No rewind-on-boundary under .off. The consumer-facing "tap Previous
94
- // at index 0 restarts the track" behaviour lives in the skipToPrevious
95
- // glue, not here.
94
+ // at index 0 restarts the track" behaviour lives in `skipToPrevious`,
95
+ // not here.
96
96
  XCTAssertNil(QueueSkipArithmetic.computePrevious(
97
97
  trackCount: 3, currentIndex: 0, repeatMode: .off))
98
98
  }
@@ -353,4 +353,34 @@ final class SkipIndexTests: XCTestCase {
353
353
  XCTAssertTrue(QueueSkipArithmetic.canSkipPrevious(
354
354
  trackCount: 3, currentIndex: 1, repeatMode: .off, restartEnabled: false))
355
355
  }
356
+
357
+ func testCanSkipPreviousRestartNothingPlayingReturnsFalseInAnyMode() {
358
+ // Nothing is loaded, so there is no track to restart and no previous
359
+ // track to reach: resolveSkipToPrevious returns no-op for this state.
360
+ for mode in [QueueSkipRepeatMode.off, .queue, .track] {
361
+ XCTAssertFalse(QueueSkipArithmetic.canSkipPrevious(
362
+ trackCount: 5, currentIndex: -1, repeatMode: mode, restartEnabled: true))
363
+ }
364
+ }
365
+
366
+ func testCanSkipPreviousAgreesWithResolveSkipToPrevious() {
367
+ // canSkipPrevious is the enabled-state of the same decision
368
+ // resolveSkipToPrevious makes; the two must never disagree.
369
+ for count in [0, 1, 3] {
370
+ for index in [-1, 0, 1, 2] {
371
+ for mode in [QueueSkipRepeatMode.off, .queue, .track] {
372
+ for restart in [false, true] {
373
+ let can = QueueSkipArithmetic.canSkipPrevious(
374
+ trackCount: count, currentIndex: index, repeatMode: mode,
375
+ restartEnabled: restart)
376
+ let resolved = QueueSkipArithmetic.resolveSkipToPrevious(
377
+ trackCount: count, currentIndex: index, repeatMode: mode,
378
+ restartEnabled: restart, positionMs: 0)
379
+ XCTAssertEqual(can, resolved != .noOp,
380
+ "count=\(count) index=\(index) mode=\(mode) restart=\(restart)")
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
356
386
  }
@@ -0,0 +1,43 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// The sleep timer's pause is a transport intent: the paths that restart
5
+ /// playback on their own (a drain refill, a buffer coming back) read
6
+ /// `wantsToPlay`, so the timer clears it the way a user pause does.
7
+ final class SleepTimerPauseIntentTests: XCTestCase {
8
+
9
+ private var player: TrackPlayer!
10
+
11
+ override func setUp() async throws {
12
+ try await super.setUp()
13
+ player = TrackPlayer()
14
+ _ = try await player.configure(config: PlayerFixtures.config()).await()
15
+ _ = try await player.setQueue(
16
+ tracks: PlayerFixtures.localTracks(count: 2, for: self), startAtIndex: 0
17
+ ).await()
18
+ }
19
+
20
+ override func tearDown() async throws {
21
+ if let player {
22
+ _ = try await player.destroy().await()
23
+ }
24
+ player = nil
25
+ try await super.tearDown()
26
+ }
27
+
28
+ func testTheTimersPauseClearsTheIntentToPlay() async throws {
29
+ let player = try XCTUnwrap(self.player)
30
+ await player.onPlayerQueue {
31
+ player.wantsToPlay = true
32
+ player.isRecoveringFromStall = true
33
+ player.applySleepTimerResult(
34
+ SleepTimerCore.Tick(fadeFraction: 1.0, pauseNow: true, stateChanged: true))
35
+ }
36
+
37
+ let state = await player.onPlayerQueueValue {
38
+ (wants: player.wantsToPlay, recovering: player.isRecoveringFromStall)
39
+ }
40
+ XCTAssertFalse(state.wants, "a drain or a buffer refill must not restart what the timer stopped")
41
+ XCTAssertFalse(state.recovering)
42
+ }
43
+ }