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,418 @@
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
+ // MARK: - The queue stops on a track whose failure was reported
238
+
239
+ /// A failure dispatched as its item is seated survives the first
240
+ /// announcement of that position, which happens in the same block.
241
+ func testAFailureAtSeatTimeStillHoldsTheQueueAfterTheTrackIsAnnounced() {
242
+ let player = TrackPlayer()
243
+ let engine = RetryEngineStub()
244
+ seed(player, engine: engine)
245
+ let qid = player.queueItemIds[0]
246
+ let item = AVPlayerItem(url: URL(string: "https://example.com/0.m4a")!)
247
+ item.queueItemId = qid
248
+
249
+ player.dispatchErrorOrRetry(
250
+ item: item,
251
+ underlying: NSError(domain: AVFoundationErrorDomain, code: -11828, userInfo: nil))
252
+ player.handleCurrentItemDidChange()
253
+
254
+ XCTAssertEqual(
255
+ player.reportedFailureOnCurrentTrack, qid,
256
+ "the track change announcing the failed track must not clear its report")
257
+ }
258
+
259
+ /// Announcing a different track clears a report the queue has moved past.
260
+ func testAnnouncingADifferentTrackClearsTheReport() {
261
+ let player = TrackPlayer()
262
+ let engine = RetryEngineStub()
263
+ seed(player, engine: engine)
264
+ player.reportedFailureQueueItemIds = [player.queueItemIds[0]]
265
+ player.currentTrackIndex = 1
266
+
267
+ player.dispatchTrackChange(reason: .userSkipNext)
268
+
269
+ XCTAssertTrue(player.reportedFailureQueueItemIds.isEmpty)
270
+ }
271
+
272
+ /// A forced reinstall builds the current position from scratch, so what was
273
+ /// reported against it no longer describes what is installed.
274
+ func testAForcedReinstallClearsTheReport() {
275
+ let player = TrackPlayer()
276
+ let engine = RetryEngineStub()
277
+ seed(player, engine: engine)
278
+ player.reportedFailureQueueItemIds = [player.queueItemIds[0]]
279
+
280
+ player.performingMutation { player.fullRebuildPlayerQueue(forcingReinstall: true) }
281
+
282
+ XCTAssertTrue(player.reportedFailureQueueItemIds.isEmpty)
283
+ }
284
+
285
+ /// A reconcile that is not rebuilding the position leaves the report alone —
286
+ /// the track it names is still the one that failed.
287
+ func testAPlainRebuildLeavesTheReportAlone() {
288
+ let player = TrackPlayer()
289
+ let engine = RetryEngineStub()
290
+ seed(player, engine: engine)
291
+ let qid = player.queueItemIds[0]
292
+ player.reportedFailureQueueItemIds = [qid]
293
+
294
+ player.performingMutation { player.fullRebuildPlayerQueue() }
295
+
296
+ XCTAssertEqual(player.reportedFailureQueueItemIds, [qid])
297
+ }
298
+
299
+ /// One `play()`, one error. Several tracks of an installed run fail at once —
300
+ /// a window of unplayable sources does exactly that — and only the track the
301
+ /// listener is on is theirs to act on; it reports once however many times it
302
+ /// or its neighbours fire. The record is a set so a repeat cannot be reopened
303
+ /// by a different track replacing it in a one-slot key.
304
+ func testOnlyTheCurrentTrackReportsAndOnlyOnce() {
305
+ let player = TrackPlayer()
306
+ let engine = RetryEngineStub()
307
+ seed(player, engine: engine)
308
+ var reported: [PlaybackError] = []
309
+ _ = player.errorListeners.add { reported.append($0) }
310
+ let fatal = NSError(domain: AVFoundationErrorDomain, code: -11828, userInfo: nil)
311
+ func item(at index: Int) -> AVPlayerItem {
312
+ let made = AVPlayerItem(url: URL(string: "https://example.com/\(index).m4a")!)
313
+ made.queueItemId = player.queueItemIds[index]
314
+ return made
315
+ }
316
+
317
+ // Two tracks failing, each reported twice, in the order that defeats a
318
+ // one-slot key: every fire names a different track than the one before it.
319
+ player.dispatchErrorOrRetry(item: item(at: 0), underlying: fatal)
320
+ player.dispatchErrorOrRetry(item: item(at: 1), underlying: fatal)
321
+ player.dispatchErrorOrRetry(item: item(at: 0), underlying: fatal)
322
+ player.dispatchErrorOrRetry(item: item(at: 1), underlying: fatal)
323
+
324
+ XCTAssertEqual(
325
+ reported.count, 1,
326
+ "one play(), one error: the track being played, once, whatever else fails")
327
+ XCTAssertEqual(
328
+ reported.first?.queueItemId, player.queueItemIds[0],
329
+ "the error names the track the listener is on")
330
+ XCTAssertEqual(
331
+ player.reportedFailureQueueItemIds, Set([player.queueItemIds[0]]),
332
+ "and only that track holds the queue")
333
+ }
334
+
335
+ /// Seating a position is an attempt at it, so a skip onto a track that
336
+ /// reported earlier clears its report. Without this the track is wedged:
337
+ /// it never reports again, its run is never extended, and it never reaches
338
+ /// an end verdict — silently, until play().
339
+ func testSkippingOntoAReportedTrackClearsItsReport() {
340
+ let player = TrackPlayer()
341
+ let engine = RetryEngineStub()
342
+ seed(player, engine: engine)
343
+ player.reportedFailureQueueItemIds = [player.queueItemIds[1]]
344
+
345
+ player.applyNewCurrentIndex(newIndex: 1, reason: .userSkipToIndex)
346
+
347
+ XCTAssertTrue(
348
+ player.reportedFailureQueueItemIds.isEmpty,
349
+ "the track being skipped onto is being tried again")
350
+ }
351
+
352
+ private func seed(_ player: TrackPlayer, engine: RetryEngineStub) {
353
+ _ = player.queueState.replaceAll((0 ..< 2).map {
354
+ TrackItem(
355
+ id: "t\($0)", url: "https://example.com/\($0).m4a", title: nil, artist: nil,
356
+ album: nil, duration: nil, artworkUrl: nil, extras: nil,
357
+ replayGainTrackGain: nil, replayGainTrackPeak: nil,
358
+ replayGainAlbumGain: nil, replayGainAlbumPeak: nil)
359
+ })
360
+ player.currentTrackIndex = 0
361
+ player.engine = engine
362
+ }
363
+ }
364
+
365
+ /// Records the seeks the retry issues; everything else is inert.
366
+ private final class RetryEngineStub: PlaybackEngine {
367
+ var position: TimeInterval = 0
368
+ private(set) var seeks: [(index: Int, position: TimeInterval)] = []
369
+
370
+ var currentPositionSeconds: TimeInterval { position }
371
+ func seek(toIndex index: Int, position: TimeInterval) {
372
+ seeks.append((index, position))
373
+ }
374
+
375
+ func setAudioMixProvider(_ provider: AudioMixProvider?) {}
376
+ func refreshAllItemMixes() {}
377
+ func release() {}
378
+ /// Holds what it is given, so a caller resolving a queue position to an
379
+ /// engine index by `queueItemId` gets the same answer a real engine would.
380
+ func setItems(_ items: [AVPlayerItem], startIndex: Int, startPositionSeconds: TimeInterval) {
381
+ installed = items
382
+ }
383
+ @discardableResult
384
+ func insertItems(_ items: [AVPlayerItem], after: AVPlayerItem?) -> Bool {
385
+ installed.append(contentsOf: items)
386
+ return true
387
+ }
388
+ func remove(_ item: AVPlayerItem) {
389
+ installed.removeAll { $0 === item }
390
+ }
391
+ func removeAllItems() {}
392
+ func canInsert(_ item: AVPlayerItem, after: AVPlayerItem?) -> Bool { true }
393
+ func play() {}
394
+ func pause() {}
395
+ func stop() {}
396
+ func skipToNext() {}
397
+ func skipToPrevious() {}
398
+ func setVolume(_ volume: Float) {}
399
+ func setPlaybackSpeed(_ speed: Float) {}
400
+ func seedPlaybackSpeed(_ speed: Float) {}
401
+ func setRepeatMode(_ mode: RepeatMode) {}
402
+ func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {}
403
+
404
+ var currentDurationSeconds: TimeInterval { 0 }
405
+ var isPlaying: Bool { false }
406
+ var currentMediaItemIndex: Int { -1 }
407
+ var bufferedPositionSeconds: TimeInterval { 0 }
408
+ var timeControlStatus: AVPlayer.TimeControlStatus { .paused }
409
+ var currentMediaItem: AVPlayerItem? { nil }
410
+ private var installed: [AVPlayerItem] = []
411
+ var allMediaItems: [AVPlayerItem] { installed }
412
+ /// No fade to schedule — this stub exercises the rest of the surface.
413
+ var isFadePendingOrActive: Bool { false }
414
+ /// Nothing attached — this stub exercises the rest of the surface.
415
+ var attachedQueueItemIds: [String] { [] }
416
+
417
+ var delegate: PlaybackEngineDelegate?
418
+ }
@@ -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
  }