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,1042 @@
1
+ import AVFoundation
2
+
3
+ extension TrackPlayer {
4
+ // MARK: - Events — dispatch
5
+
6
+ /// Called from the `\.currentItem` KVO dispatch in
7
+ /// `installGaplessObservers`. Fires `onTrackChange` whenever the
8
+ /// player's current item changes, and — when the change is
9
+ /// "currentItem went nil while the queue still had tracks" outside
10
+ /// of an in-flight mutation — also fires `onQueueEnd` and emits an
11
+ /// `.ended` state change.
12
+ ///
13
+ /// Mid-mutation nil fires are suppressed entirely. Every
14
+ /// `removeAllItems` + `insert` pair produces a transient
15
+ /// `currentItem == nil` observation before the real new item
16
+ /// lands; without the `isMutatingPlayerQueue` guard we'd both
17
+ /// consume `pendingTrackChangeReason` against the intermediate
18
+ /// fire AND spuriously emit `onQueueEnd` on every backward skip,
19
+ /// setQueue, full rebuild, etc.
20
+ internal func handleCurrentItemDidChange() {
21
+ if self.isMutatingPlayerQueue && self.player?.currentItem == nil {
22
+ return
23
+ }
24
+ // Snapshot timestamp first thing so `transitionGapMs` measures the
25
+ // iOS-side notification→KVO scheduling delta, not the synchronous
26
+ // lib work that follows. Always overwritten — cheap UInt64 store.
27
+ self.gaplessGapMeasuredAtNs = DispatchTime.now().uptimeNanoseconds
28
+ let reason = consumePendingReasonAndResyncIndex()
29
+ dispatchTrackChange(reason: reason)
30
+ // Native auto-advance can change `currentTrackIndex` (via the
31
+ // matchTrackIndex resync) without going through any explicit
32
+ // mutation method. Recompute capabilities here so the boundary-
33
+ // flip canSkipNext under `.off` (last-track auto-advance leaving
34
+ // cur on the last track with no next) lands.
35
+ self.recomputeCapabilities()
36
+ detectQueueEnd()
37
+ // The matchTrackIndex resync makes `currentTrackIndex` canonical
38
+ // post-auto-advance, so rescheduling the prefetcher window from
39
+ // here shifts it to cur+1..cur+lookaheadCount against the
40
+ // freshly-synced index. The prefetcher dedupes by url, so
41
+ // re-issuing the same window mid-prefetch is cheap.
42
+ self.rescheduleLookahead()
43
+ // Last: the append resolves against the freshly-pointed prefetcher, and
44
+ // its synchronous item construction stays behind the user-visible
45
+ // track change rather than in front of it.
46
+ self.topUpWindow()
47
+ }
48
+
49
+ /// Consume `pendingTrackChangeReason` + `didPlayToEndPending` and,
50
+ /// when the player has auto-advanced naturally, resync
51
+ /// `currentTrackIndex` from the player's current AVPlayerItem via
52
+ /// `matchTrackIndex`. Returns the consumed reason for the caller
53
+ /// to thread into `dispatchTrackChange`. Defaults to `.autoAdvance`
54
+ /// when no user mutation stashed a reason.
55
+ ///
56
+ /// Resync requires BOTH `didPlayToEndPending` set AND no pending
57
+ /// user-mutation reason. User mutations pre-write
58
+ /// `currentTrackIndex` before the rebuild — that intent is
59
+ /// authoritative. The reason gate covers the FB9221518 case where
60
+ /// a paused-state `removeAllItems` fires DidPlayToEndTime for a
61
+ /// queue item that was never played; without it the post-rebuild
62
+ /// `\.currentItem` KVO, once on `playerQueue`, would resync to
63
+ /// AVQueuePlayer's stale `currentItem` value (the pre-rebuild item).
64
+ private func consumePendingReasonAndResyncIndex() -> TrackChangeReason {
65
+ // Resync gate: only when the player auto-advanced naturally
66
+ // (no pending user-mutation reason AND DidPlayToEndTime fired).
67
+ //
68
+ // Two gates AND-ed together because each catches a distinct
69
+ // wrong-resync vector:
70
+ //
71
+ // 1. `didPlayToEndPending`: chain-advance bounces during a
72
+ // `removeAllItems` + insert rebuild do NOT fire the notification,
73
+ // so this gate excludes those.
74
+ // 2. `pendingUserMutation`: a paused-state queue mutation
75
+ // (skipToIndex, skipPrev, skipNext via fullRebuild) under FB9221518
76
+ // can fire `AVPlayerItemDidPlayToEndTime` for an item being
77
+ // pulled from the queue. Without this gate the fired
78
+ // notification lands at the post-mutation `\.currentItem` KVO
79
+ // hop to `playerQueue`, sees `didPlayToEndPending == true`, and resyncs
80
+ // `currentTrackIndex` to whatever AVQueuePlayer's
81
+ // not-yet-published `currentItem` resolves to — which can be
82
+ // the pre-mutation item's queueItemId. The user-mutation pre-
83
+ // write of `currentTrackIndex` is authoritative; the resync
84
+ // must defer to it.
85
+ let pendingUserMutation = self.pendingTrackChangeReason != nil
86
+ let reason = self.pendingTrackChangeReason ?? .autoAdvance
87
+ self.pendingTrackChangeReason = nil
88
+ let endFired = self.didPlayToEndPending
89
+ self.didPlayToEndPending = false
90
+ // A queue stopped on a reported failure does not re-derive its index from
91
+ // the player. `removeAllItems` fires `AVPlayerItemDidPlayToEndTime` for an
92
+ // item that never played (FB9221518), and the re-seat below issues one, so
93
+ // without this a later pass would resync off the parked position onto the
94
+ // track behind it — the move the stop exists to prevent. The report does
95
+ // not exist yet on the pass that files it, so that pass still runs.
96
+ if endFired && !pendingUserMutation && self.reportedFailureOnCurrentTrack == nil {
97
+ if let derived = matchTrackIndex(forCurrentItem: self.player?.currentItem) {
98
+ // One natural advance that lands more than one position on means the
99
+ // player dropped what it skipped: `AVQueuePlayer` discards an item that
100
+ // cannot be played, and a track that failed while the previous one was
101
+ // still playing is never seated, so nothing ever observes its failure —
102
+ // the current-item status observer is the only load-failure path and it
103
+ // never sees that item. Carrying the queue past it would skip a track
104
+ // the listener never heard and was never told about.
105
+ if derived > self.currentTrackIndex + 1 {
106
+ self.stopOnDroppedTrack(at: self.currentTrackIndex + 1)
107
+ } else {
108
+ self.currentTrackIndex = derived
109
+ }
110
+ }
111
+ }
112
+ return reason
113
+ }
114
+
115
+
116
+ /// Stop on a track the player dropped mid-advance, report it, and park.
117
+ ///
118
+ /// Nothing has observed this track fail — `AVQueuePlayer` removed it rather
119
+ /// than seating it — so the report is raised here, against the position.
120
+ /// `fileFormatNotRecognized` is chosen for being fatal, which is what the
121
+ /// stop needs: the same function drops the play intent, and doing that under
122
+ /// a transient code would contradict `dispatchErrorOrRetry`, which refuses to.
123
+ /// The library does not know *why* the player dropped it — a 404, a DNS
124
+ /// failure and a non-media body all land here — so the code is a statement
125
+ /// about recoverability, not about the body.
126
+ private func stopOnDroppedTrack(at index: Int) {
127
+ guard let qid = self.queueItemIds[safe: index] else { return }
128
+ self.currentTrackIndex = index
129
+ self.engine?.pause()
130
+ self.wantsToPlay = false
131
+ // The player discarded this position's item and is sitting on one further
132
+ // on, so every read that answers from the seated item — duration,
133
+ // seekability, the progress payload's position — would describe a track the
134
+ // listener is not on. Re-seat the failed position so the index and the item
135
+ // agree. Forced, because the item keeps its `queueItemId` and an identity
136
+ // comparison would find the slice already correct.
137
+ //
138
+ // Read before the rebuild, which empties the set: a track the status
139
+ // observer already reported is one the consumer has been told about, and
140
+ // the drop is the same failure arriving by a second route.
141
+ let alreadyReported = self.reportedFailureQueueItemIds.contains(qid)
142
+ // The report is filed after: a forced rebuild clears the set, and the
143
+ // re-seated item failing again is then suppressed by this entry, so one
144
+ // dropped track still reports once.
145
+ self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
146
+ self.reportedFailureQueueItemIds.insert(qid)
147
+ // The queue still parks — index, pause, intent and the re-seat above all
148
+ // stand — it just does not say so twice.
149
+ if alreadyReported { return }
150
+ let err = self.buildPlaybackError(
151
+ nil,
152
+ mapped: .fileFormatNotRecognized,
153
+ queueItemId: qid,
154
+ url: self.tracks[safe: index]?.url ?? "",
155
+ nativeDomainOverride: "PLAYER_ITEM_DROPPED"
156
+ )
157
+ self.errorListeners.forEach { $0(err) }
158
+ self.emitState(.error, reason: .error)
159
+ }
160
+
161
+ /// Debug-only log of the classifier's verdict for the current item next to
162
+ /// the URL its asset is bound to and its `sourceURL`.
163
+ ///
164
+ /// The verdict is `classifyTrackSource`'s, computed by the caller from the
165
+ /// track URL, whether the item is routed through the read-through server,
166
+ /// and whether the cache holds the whole file. The asset URL decides
167
+ /// nothing: for a routed item it is the server's `127.0.0.1` address
168
+ /// whether the bytes come from disk or the origin. Queries are stripped: a
169
+ /// source URL carries a token in its query for any server that
170
+ /// authenticates that way.
171
+ private func logPlaybackSource(_ classified: TrackSource?) {
172
+ #if DEBUG
173
+ guard let item = self.engine?.currentMediaItem else { return }
174
+ let assetURL = (item.asset as? AVURLAsset)?.url.absoluteString ?? "<non-url asset>"
175
+ let source = item.sourceURL ?? "<none>"
176
+ let origin = classified.map { "\($0)" } ?? "unknown"
177
+ NSLog(
178
+ "[RNQP-DIAG] now-playing from=%@ asset=%@ source=%@",
179
+ origin,
180
+ PlaybackErrorMapping.stripQuery(assetURL),
181
+ PlaybackErrorMapping.stripQuery(source)
182
+ )
183
+ #endif
184
+ }
185
+
186
+ /// Re-derive `currentTrackSource` for whatever `currentTrackIndex` now
187
+ /// points at. Caller is on `playerQueue`.
188
+ internal func reclassifyCurrentTrackSource() {
189
+ let idx = self.currentTrackIndex
190
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
191
+ ? self.tracks[idx] : nil
192
+ let cache = self.lookaheadCache
193
+ // Whether this position's item reads through the server is decided when
194
+ // the item is built and stamped on it, so the installed item is asked
195
+ // first — any item in the run, not only the seated one, so the answer
196
+ // does not depend on the engine having advanced yet. A position with no
197
+ // item installed is answered on the terms `makeItem` will use for it.
198
+ // A disabled cache serves nothing, so nothing is `cached` while it is off.
199
+ let stamped: Bool? = engineIndex(forQueuePosition: idx).flatMap { engineIdx in
200
+ self.engine?.allMediaItems[safe: engineIdx]?.isRoutedThroughReadThroughServer
201
+ }
202
+ let isRouted = self.lookaheadConfig.enabled
203
+ && (stamped ?? (self.routingServer != nil
204
+ && (track.map { AVQueueBuilder.isRoutable($0.url) } ?? false)))
205
+ self.currentTrackSource = TrackPlayer.classifyTrackSource(
206
+ url: track?.url, isRouted: isRouted
207
+ ) { url in
208
+ cache?.isFullyCached(url: url) ?? false
209
+ }
210
+ logPlaybackSource(self.currentTrackSource)
211
+ }
212
+
213
+ /// Fire `trackChangeListeners` for the current `(track, idx,
214
+ /// reason)`, deduped on `lastEmittedQueueItemId` (NOT idx — index
215
+ /// alone would suppress setQueue replacements that keep cur=0 with
216
+ /// a different first track). On a real fire, also resets the
217
+ /// error-dedup state so a new item's identical-coded error fires.
218
+ internal func dispatchTrackChange(reason: TrackChangeReason) {
219
+ let idx = self.currentTrackIndex
220
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
221
+ ? self.tracks[idx] : nil
222
+ let currentItemId: String? = self.queueItemIds[safe: idx]
223
+ self.reclassifyCurrentTrackSource()
224
+ if self.lastEmittedQueueItemId != currentItemId {
225
+ self.lastEmittedQueueItemId = currentItemId
226
+ self.lastErrorQueueItemId = nil
227
+ self.lastErrorCode = nil
228
+ // A report against some OTHER track says nothing about the one now being
229
+ // announced, so it goes. A report against the track being announced
230
+ // stands: an item that is already failed when it is seated has its error
231
+ // dispatched before this, the first announcement of its position, and
232
+ // clearing here would leave the queue with nothing to hold on.
233
+ self.reportedFailureQueueItemIds = self.reportedFailureQueueItemIds
234
+ .intersection(currentItemId.map { [$0] } ?? [])
235
+ // A real track (re)loaded => no longer at the end of the queue. At true
236
+ // end-of-queue this block is skipped entirely — the last-track id still
237
+ // matches `lastEmittedQueueItemId` — so `reachedQueueEnd` stays set; the
238
+ // `idx >= 0` guard only keeps an explicit clear to index -1 from touching it.
239
+ if idx >= 0 { self.reachedQueueEnd = false }
240
+ // New active track => new milestone playthrough. This is the single
241
+ // deduped track-change point (the crossfade fade-start echo collapses
242
+ // here), so it resets exactly once per real track change.
243
+ self.milestoneTracker.reset()
244
+ // New track => its initial load counts as `buffering` (not `stalled`)
245
+ // until it reaches a playing state, and it starts not-fully-buffered.
246
+ self.hasStartedPlaying = false
247
+ self.recomputeFullyBuffered()
248
+ // Measure the gapless auto-advance silence gap (nil for every
249
+ // other transition) before the emit so it rides on onTrackChange
250
+ // as `transitionGapMs`. The timestamp was captured at the top of
251
+ // handleCurrentItemDidChange, above this method's synchronous lib
252
+ // work; falls back to now() if the snapshot wasn't taken.
253
+ var transitionGapMs: Double? = nil
254
+ if reason == .autoAdvance && self.gaplessGapPrevEndedAtNs > 0 {
255
+ let now = self.gaplessGapMeasuredAtNs > 0
256
+ ? self.gaplessGapMeasuredAtNs
257
+ : DispatchTime.now().uptimeNanoseconds
258
+ let gapNs = now &- self.gaplessGapPrevEndedAtNs
259
+ transitionGapMs = Double(gapNs) / 1_000_000.0
260
+ self.gaplessGapPrevEndedAtNs = 0
261
+ self.gaplessGapMeasuredAtNs = 0
262
+ #if DEBUG
263
+ self.gaplessLogLastChangedAtNs = now
264
+ NSLog("[RNQP-GAPLESS] item-changed trackIdx=%d ts_ns=%llu delta_since_ended_ms=%.3f",
265
+ idx, now, transitionGapMs ?? 0)
266
+ #endif
267
+ }
268
+ // Cross-platform listener-fire order: track-change first so the
269
+ // consumer's track-keyed handlers run before the format-refresh
270
+ // emits null. Matches Android's onMediaItemTransition →
271
+ // refreshNowPlayingFormatForActiveItem ordering.
272
+ trackChangeListeners.forEach { $0(track, Double(idx), reason, transitionGapMs) }
273
+ self.refreshNowPlayingFormatForActiveItem()
274
+ self.nowPlayingInfo.refreshAll(self.nowPlayingSnapshot())
275
+ // Kick the network resolve. The placeholder is already on the
276
+ // lock-screen via refreshAll; the resolver swaps in the real
277
+ // bitmap when the fetch lands. ArtworkResolver's single in-
278
+ // flight + last-URL guard drops a stale fetch if another track
279
+ // change fires before completion.
280
+ self.artworkResolver.resolve(url: track?.artworkUrl) { [weak self] image in
281
+ self?.nowPlayingInfo.applyArtwork(image)
282
+ }
283
+ }
284
+ }
285
+
286
+ /// Classify the active-track playback source.
287
+ ///
288
+ /// `.cached` means the bytes being played are coming off local disk, which
289
+ /// takes two things: the cache holds the track in full, **and** the item is
290
+ /// bound to the read-through server, which is what reads from the cache. A
291
+ /// track sitting complete on disk that the player is not routed through is
292
+ /// still being streamed, and saying otherwise is how this reported `cached`
293
+ /// while every byte came from the origin.
294
+ ///
295
+ /// `nil` when the URL is nil or its scheme is neither `file://` nor
296
+ /// `http(s)://` — `data:`, `blob:` and custom schemes have no contract here.
297
+ internal static func classifyTrackSource(
298
+ url: String?, isRouted: Bool, isUrlCached: (String) -> Bool
299
+ ) -> TrackSource? {
300
+ guard let url = url else { return nil }
301
+ if url.hasPrefix("file://") { return .local }
302
+ let isHttp = url.hasPrefix("http://") || url.hasPrefix("https://")
303
+ guard isHttp else { return nil }
304
+ guard isRouted else { return .streaming }
305
+ return isUrlCached(url) ? .cached : .streaming
306
+ }
307
+
308
+ /// Fires from the `addPeriodicTimeObserver` tick (gapless mode)
309
+ /// or the engine-agnostic `progressFallbackTimer` (crossfade
310
+ /// mode, where `self.player` is nil). Reports current position +
311
+ /// duration + seconds-buffered-ahead-of-position.
312
+ ///
313
+ /// The two paths source position differently: gapless reads off
314
+ /// `self.player.currentItem` directly; crossfade reads off
315
+ /// `engine.currentPositionSeconds` / `currentDurationSeconds` /
316
+ /// `bufferedPositionSeconds` (computed properties on the
317
+ /// engine surface that delegate to whichever leg is leading).
318
+ internal func emitProgressIfSubscribed() {
319
+ guard !progressListeners.isEmpty else { return }
320
+ let position: TimeInterval
321
+ let duration: TimeInterval
322
+ let buffered: TimeInterval
323
+ // Whether the playhead is actively advancing. The throttle applies only
324
+ // while playing (the streaming firehose); a paused player's ticks are
325
+ // discrete (seek / stop / repeat-rewind) and must always deliver so the UI
326
+ // reflects the move — otherwise a paused seek under a >500ms interval is
327
+ // lost until playback resumes.
328
+ let isPlaying: Bool
329
+ if let item = self.player?.currentItem {
330
+ position = CMTimeGetSeconds(item.currentTime())
331
+ let rawDuration = CMTimeGetSeconds(item.duration)
332
+ duration = (rawDuration.isFinite && rawDuration > 0) ? rawDuration : 0.0
333
+ buffered = loadedAheadSeconds(for: item, currentPosition: position)
334
+ isPlaying = (self.player?.rate ?? 0) != 0
335
+ } else if let engine = self.engine {
336
+ // Engine-driven path (CrossfadeEngine): the fallback timer fires
337
+ // unconditionally. While paused, emit ONLY when the playhead moved since
338
+ // the last emit (a seek / stop / repeat-rewind), mirroring the gapless
339
+ // periodic observer which fires once on seek completion even while
340
+ // paused — otherwise the post-stop "position reset to 0" tick never
341
+ // reaches consumers. A steady pause (position unchanged) still skips, so
342
+ // identical tuples aren't re-published every 0.5s.
343
+ position = engine.currentPositionSeconds
344
+ // Qualify Swift.abs: a bare `abs(...)` is ambiguous against Darwin's
345
+ // C `abs` when that's in scope (the library build context), so name
346
+ // the Swift overload explicitly — matches BiquadCoefficients.
347
+ let playheadDelta = Swift.abs(position - self.lastEmittedCrossfadePosition)
348
+ let moved = playheadDelta > 0.001
349
+ guard engine.isPlaying || moved else { return }
350
+ self.lastEmittedCrossfadePosition = position
351
+ duration = engine.currentDurationSeconds
352
+ let bufferedAbs = engine.bufferedPositionSeconds
353
+ buffered = max(0, bufferedAbs - position)
354
+ isPlaying = engine.isPlaying
355
+ } else {
356
+ return
357
+ }
358
+ let safePosition = position.isFinite ? position : 0.0
359
+ let safeDuration = duration.isFinite ? duration : 0.0
360
+ let progress = PlayerProgress(
361
+ position: safePosition, duration: safeDuration, buffered: buffered)
362
+ // Throttle only the playing firehose; paused ticks (discrete seek/stop
363
+ // results) always deliver. The crossfade movement bookkeeping above already
364
+ // ran this tick, and milestones + buffer state are separate calls, so both
365
+ // stay full-rate regardless of the throttle.
366
+ guard !isPlaying || self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else {
367
+ return
368
+ }
369
+ progressListeners.forEach { $0(progress) }
370
+ }
371
+
372
+ /// Advance the milestone tracker off the same periodic tick and emit any
373
+ /// 25/50/75/90% thresholds forward playback just crossed. Runs the
374
+ /// tracker even with no subscribers (so a mid-stream subscribe doesn't
375
+ /// retroactively fire already-passed thresholds); emission is gated on
376
+ /// having listeners.
377
+ internal func processMilestones() {
378
+ // No active track => nothing to attribute a milestone to (mirrors the
379
+ // Android guard; avoids emitting with trackIndex -1 on a stale read
380
+ // during a clear / queue-end transition).
381
+ guard !self.tracks.isEmpty, self.currentTrackIndex >= 0 else { return }
382
+ let position: TimeInterval
383
+ let playerDuration: TimeInterval
384
+ if let item = self.player?.currentItem {
385
+ position = CMTimeGetSeconds(item.currentTime())
386
+ let raw = CMTimeGetSeconds(item.duration)
387
+ playerDuration = (raw.isFinite && raw > 0) ? raw : 0
388
+ } else if let engine = self.engine {
389
+ position = engine.currentPositionSeconds
390
+ playerDuration = engine.currentDurationSeconds
391
+ } else {
392
+ return
393
+ }
394
+ guard position.isFinite else { return }
395
+ let duration = effectiveMilestoneDuration(playerDuration: playerDuration)
396
+ let crossed = milestoneTracker.tick(durationSec: duration, positionSec: position)
397
+ guard !crossed.isEmpty, !milestoneListeners.isEmpty else { return }
398
+ let idx = Double(self.currentTrackIndex)
399
+ for m in crossed {
400
+ milestoneListeners.forEach { $0(Double(m), idx) }
401
+ }
402
+ }
403
+
404
+ /// Player-reported duration when known (> 0), else the consumer-supplied
405
+ /// `TrackItem.duration` (seconds) for the active track, else 0 (no
406
+ /// computable duration → no milestones).
407
+ internal func effectiveMilestoneDuration(playerDuration: TimeInterval) -> TimeInterval {
408
+ if playerDuration > 0 { return playerDuration }
409
+ let idx = self.currentTrackIndex
410
+ if idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 {
411
+ return d
412
+ }
413
+ return 0
414
+ }
415
+
416
+ /// Map an `AVPlayerItem` back to an index in `self.tracks`.
417
+ ///
418
+ /// Lookup is keyed on the lib-generated `queueItemId`
419
+ /// associated-object set by `AVQueueBuilder.makeItem` at
420
+ /// construction. `self.queueItemIds` is a parallel `[String]`
421
+ /// array maintained 1:1 with `self.tracks` at every queue
422
+ /// mutation site; `firstIndex(of:)` resolves the AVPlayerItem's
423
+ /// `queueItemId` to its position in O(N) — N is queue size,
424
+ /// typical < 100 tracks, no memoization warranted.
425
+ ///
426
+ /// queueItemId is unique per construction, so each AVPlayerItem
427
+ /// maps to exactly one queue position even when the consumer
428
+ /// supplies the same `track.url` at multiple positions.
429
+ ///
430
+ /// Returns nil when `item` is nil, when the item lacks a
431
+ /// queueItemId (e.g. constructed outside `AVQueueBuilder` —
432
+ /// shouldn't happen in production), or when the queueItemId
433
+ /// isn't in `self.queueItemIds` (post-mutation desync — also
434
+ /// shouldn't happen, but defensive). The caller leaves
435
+ /// `currentTrackIndex` unchanged on a nil result.
436
+ internal func matchTrackIndex(forCurrentItem item: AVPlayerItem?) -> Int? {
437
+ guard let id = item?.queueItemId else { return nil }
438
+ return self.queueItemIds.firstIndex(of: id)
439
+ }
440
+
441
+ /// Translate a queue position into the active engine's own index space by
442
+ /// item identity. An engine indexes within the slice it was handed, so a
443
+ /// queue position is only usable after this lookup.
444
+ ///
445
+ /// `nil` means the track is not materialised in the engine right now. That
446
+ /// is a rebuild signal, not an error — the caller reinstalls the queue at
447
+ /// the wanted position rather than issuing a seek the engine would reject.
448
+ internal func engineIndex(forQueuePosition position: Int) -> Int? {
449
+ guard let engine = self.engine,
450
+ position >= 0, position < self.queueItemIds.count else { return nil }
451
+ let id = self.queueItemIds[position]
452
+ return engine.allMediaItems.firstIndex { $0.queueItemId == id }
453
+ }
454
+
455
+ /// Compute "seconds buffered ahead of position" — consumers use
456
+ /// this for the "buffered track" overlay on a progress bar. Takes
457
+ /// the end of the last loaded `CMTimeRange`, subtracts the current
458
+ /// position, and clamps to zero. Returns 0 when no ranges are
459
+ /// loaded or when the subtraction is non-finite.
460
+ private func loadedAheadSeconds(
461
+ for item: AVPlayerItem, currentPosition: Double
462
+ ) -> Double {
463
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return 0 }
464
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
465
+ guard end.isFinite, currentPosition.isFinite else { return 0 }
466
+ return max(0, end - currentPosition)
467
+ }
468
+
469
+ /// Arm stall recovery. AVPlayer reports the underrun and then does nothing:
470
+ /// with `automaticallyWaitsToMinimizeStalling` off it will not resume when
471
+ /// the buffer refills, so `recomputeBufferState` re-issues play once it does.
472
+ ///
473
+ /// Gated on transport intent — a stall that arrives while the user is paused
474
+ /// must not arm a resume that fires later and starts audio they did not ask
475
+ /// for.
476
+ @objc internal func handlePlaybackStalled(_ notification: Notification) {
477
+ // The observer is registered for every item in the process; only the
478
+ // item being heard can stall playback. A crossfade standby leg stalling
479
+ // in preroll, or another player's item, must not arm a resume.
480
+ let stalled = notification.object as? AVPlayerItem
481
+ playerQueue.async {
482
+ guard self.wantsToPlay, let stalled, stalled === self.activeMediaItem else { return }
483
+ self.isRecoveringFromStall = true
484
+ // The buffer can refill before this notification is delivered. The
485
+ // keep-up flag only reports on change, so waiting for the next edge
486
+ // would wait forever — recompute now and let it settle the resume.
487
+ self.recomputeBufferState()
488
+ }
489
+ }
490
+
491
+ /// Recompute the current buffer state from the active engine item, advance
492
+ /// the per-track buffered high-water mark, and fire `onBufferStateChange`
493
+ /// when the discrete state actually changes. Runs on the buffer KVO, each
494
+ /// progress tick, the transport methods, and at track change.
495
+ ///
496
+ /// `.full` = keeps up (`playbackLikelyToKeepUp` / `playbackBufferFull`).
497
+ /// A not-full loaded item is `.stalled` when playback had started and is
498
+ /// intended (a mid-play rebuffer) or `.buffering` otherwise (initial load /
499
+ /// loading while paused). `.empty` means nothing is loaded to play.
500
+ internal func recomputeBufferState() {
501
+ let item = self.engine?.currentMediaItem
502
+ // A newly seated item announces its buffer state fresh. Drop the emitted
503
+ // baseline when the active item's queueItemId changes so the first recompute
504
+ // for the new item emits even if it lands on the same discrete state the
505
+ // previous item ended on — a `.full` queue reloaded to another `.full`
506
+ // local track otherwise catches no intermediate sample and emits nothing.
507
+ let itemId = item?.queueItemId
508
+ if itemId != self.lastBufferStateQueueItemId {
509
+ self.lastBufferStateQueueItemId = itemId
510
+ self.lastEmittedBufferState = nil
511
+ }
512
+ let computed: BufferState
513
+ if let item {
514
+ if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
515
+ computed = .full
516
+ } else {
517
+ computed = (self.hasStartedPlaying && self.wantsToPlay) ? .stalled : .buffering
518
+ }
519
+ } else {
520
+ computed = .empty
521
+ }
522
+ self.currentBufferState = computed
523
+ // The buffer has recovered and the user still wants playback: nothing else
524
+ // will restart it. Not while the session is interrupted — the refill can
525
+ // land mid-call, and the interruption's `.ended` is what resumes then.
526
+ // Not while the queue is stopped on a reported failure either: the player
527
+ // has already moved onto the track behind it, and a refill is not the
528
+ // listener asking for that track.
529
+ if self.isRecoveringFromStall, computed == .full, self.wantsToPlay,
530
+ !self.isInterrupted, self.reportedFailureOnCurrentTrack == nil {
531
+ self.isRecoveringFromStall = false
532
+ self.beginPlayback()
533
+ }
534
+ guard computed != self.lastEmittedBufferState else { return }
535
+ self.lastEmittedBufferState = computed
536
+ bufferStateListeners.forEach { $0(computed) }
537
+ }
538
+
539
+ /// The whole track has downloaded — the loaded range has reached the track's
540
+ /// duration. When the player can't report a duration (e.g. a transcoded
541
+ /// stream) it falls back to the consumer-supplied `track.duration`, the same
542
+ /// fallback milestones use. A live / indefinite source never completes.
543
+ /// (`isPlaybackBufferFull` is deliberately NOT used — it means the fixed
544
+ /// buffer is full, not that the whole track has downloaded.)
545
+ private func computeFullyBuffered() -> Bool {
546
+ guard let item = self.engine?.currentMediaItem else { return false }
547
+ if item.duration.isIndefinite { return false }
548
+ // A local file or a fully-cached source is entirely on disk — fully
549
+ // available regardless of the playback buffer.
550
+ if self.currentTrackSource == .local || self.currentTrackSource == .cached { return true }
551
+ var dur = CMTimeGetSeconds(item.duration)
552
+ if !dur.isFinite || dur <= 0 {
553
+ let idx = self.currentTrackIndex
554
+ guard idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 else {
555
+ return false
556
+ }
557
+ dur = d
558
+ }
559
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return false }
560
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
561
+ return end.isFinite && end >= dur - Self.fullyBufferedEpsilonSeconds
562
+ }
563
+
564
+ /// Recompute fully-buffered and fire `onFullyBufferedChange` on a change.
565
+ /// Runs on the buffer-full KVO, the progress tick (the loaded range grows),
566
+ /// and at track change.
567
+ internal func recomputeFullyBuffered() {
568
+ let computed = computeFullyBuffered()
569
+ self.currentFullyBuffered = computed
570
+ guard computed != self.lastEmittedFullyBuffered else { return }
571
+ self.lastEmittedFullyBuffered = computed
572
+ fullyBufferedListeners.forEach { $0(computed) }
573
+ }
574
+
575
+ /// Cap `target` at the current buffered extent when `clampSeekToBuffered`
576
+ /// is enabled and the target is past what has buffered. A local or fully-
577
+ /// buffered track reports `buffered >= duration >= target`, so nothing is
578
+ /// clamped and a seek reaches the end freely; a partly-buffered stream caps
579
+ /// at the downloaded edge. Reads the live buffered position (not a tracked
580
+ /// mark) so a seek-back that evicted the ahead-buffer clamps to what's there.
581
+ internal func clampToBufferedIfEnabled(_ target: Double) -> Double {
582
+ guard self.config.clampSeekToBuffered == true else { return target }
583
+ guard let buffered = self.engine?.bufferedPositionSeconds, buffered.isFinite, buffered > 0 else {
584
+ return target
585
+ }
586
+ return target > buffered ? buffered : target
587
+ }
588
+
589
+ /// Translate `AVPlayer.timeControlStatus` into our `PlayerState`
590
+ /// enum and fire `onStateChange` when the value actually changed.
591
+ /// Consumes `pendingStateChangeReason` (set by transport methods
592
+ /// before they call `play`/`pause`/`stop`, or by the AudioSession
593
+ /// handlers for interruption/route-change events) so user-
594
+ /// initiated transitions are labelled correctly while purely
595
+ /// system-internal ones default to `.system`.
596
+ ///
597
+ /// `.buffering` is a system-internal intermediate state — it
598
+ /// shows up between `.paused` → `.playing` while AVPlayer fills
599
+ /// its buffer. We deliberately do NOT consume the pending reason
600
+ /// for `.buffering` so the reason survives to the terminal
601
+ /// `.playing` emit. Otherwise a play() after interruption would
602
+ /// label buffering `.interruption` and the actual `.playing` as
603
+ /// `.system`, which consumers won't expect.
604
+ internal func emitStateChangeIfChanged() {
605
+ guard let engine = self.engine else { return }
606
+ let computed = PlayerStateDerivation.translate(
607
+ status: engine.timeControlStatus,
608
+ currentIndex: self.currentTrackIndex,
609
+ hasCurrentItem: engine.currentMediaItem != nil,
610
+ reachedQueueEnd: self.reachedQueueEnd
611
+ )
612
+ emitState(computed, reason: reasonForComputedState(computed))
613
+ }
614
+
615
+ /// Reason for a derived `PlayerState`, shared by the gapless KVO
616
+ /// (`emitStateChangeIfChanged`) and the crossfade delegate
617
+ /// (`engineStateMaybeChanged`) so both label states identically.
618
+ /// `.buffering` is intermediate — pass through as `.system` and leave the
619
+ /// pending reason for the terminal emit. `.ended` is always end-of-queue —
620
+ /// stamp `.queueEnd` to match `detectQueueEnd` / `enginePlaybackEnded`.
621
+ internal func reasonForComputedState(_ computed: PlayerState) -> StateChangeReason {
622
+ if computed == .buffering {
623
+ return .system
624
+ }
625
+ if computed == .ended {
626
+ self.pendingStateChangeReason = nil
627
+ return .queueEnd
628
+ }
629
+ let reason = self.pendingStateChangeReason ?? .system
630
+ self.pendingStateChangeReason = nil
631
+ return reason
632
+ }
633
+
634
+ /// Bypass the timeControlStatus translation and emit a specific
635
+ /// state directly (used for `.ended` / `.error` which AVPlayer
636
+ /// doesn't model as timeControlStatus).
637
+ internal func emitState(_ state: PlayerState, reason: StateChangeReason) {
638
+ // Playback (re)starting clears `reachedQueueEnd` — do this BEFORE the
639
+ // dedup guard so a redundant `.playing` re-emit still clears it.
640
+ if state == .playing { self.reachedQueueEnd = false }
641
+ guard state != self.lastReportedState else { return }
642
+ self.lastReportedState = state
643
+ // Once the track actually plays, a later not-full buffer is a mid-playback
644
+ // `stalled`, not the initial `buffering` load.
645
+ if state == .playing { self.hasStartedPlaying = true }
646
+ self.stateChangeListeners.forEach { $0(state, reason) }
647
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
648
+ }
649
+
650
+ /// Internal entry point for `CastEventBridge` to fan receiver-driven
651
+ /// state into the JS-facing event surface. Goes through the same
652
+ /// `emitState` dedup chokepoint as local-engine transitions, so JS
653
+ /// sees one event per real receiver transition regardless of source.
654
+ func emitRemoteState(_ state: PlayerState, reason: StateChangeReason) {
655
+ // Arrives on main from the Cast SDK's listeners; `emitState` writes the
656
+ // reported-state fields that the local path owns.
657
+ playerQueue.async { self.emitState(state, reason: reason) }
658
+ }
659
+
660
+ /// Fan a cast receiver's position update to JS `onProgress` so the
661
+ /// consumer's player screen (progress bar / position / duration) tracks
662
+ /// the receiver while a remote session is active. The receiver reports
663
+ /// milliseconds; `PlayerProgress` is in seconds. Buffered arrives as an
664
+ /// absolute receiver position, so it's reduced to "ahead of position"
665
+ /// to match the local path's semantics. The local now-playing / format
666
+ /// path is untouched — the local engine is silent during cast.
667
+ func emitRemoteProgress(positionMs: Int64, durationMs: Int64, bufferedMs: Int64) {
668
+ // Arrives on main from the Cast SDK; the throttle it shares with the local
669
+ // tick is player state.
670
+ playerQueue.async {
671
+ self.emitRemoteProgressOnQueue(
672
+ positionMs: positionMs, durationMs: durationMs, bufferedMs: bufferedMs)
673
+ }
674
+ }
675
+
676
+ private func emitRemoteProgressOnQueue(
677
+ positionMs: Int64, durationMs: Int64, bufferedMs: Int64
678
+ ) {
679
+ guard !progressListeners.isEmpty else { return }
680
+ // Same throttle as the local tick, shared instance — a consumer's
681
+ // configured cadence caps cast progress too (the receiver pushes at its own
682
+ // rate, so the effective cadence is min(receiver rate, configured interval)).
683
+ guard self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else { return }
684
+ let position = TimeInterval(max(0, positionMs)) / 1000.0
685
+ let duration = TimeInterval(max(0, durationMs)) / 1000.0
686
+ let buffered = TimeInterval(max(0, bufferedMs - positionMs)) / 1000.0
687
+ let progress = PlayerProgress(position: position, duration: duration, buffered: buffered)
688
+ progressListeners.forEach { $0(progress) }
689
+ }
690
+
691
+ /// Fan a cast receiver's track advance to JS `onTrackChange` and update
692
+ /// the authoritative `currentTrackIndex` so the consumer's player screen
693
+ /// follows the receiver's current track. Mirrors the local dispatch's
694
+ /// listener fire without the local-engine now-playing refresh (the cast
695
+ /// lockscreen is driven separately via `CastNowPlayingController`).
696
+ /// Called on the main thread from `CastEventBridge`, like `emitRemoteState`.
697
+ func emitRemoteTrackChange(index: Int) {
698
+ // Arrives on main from the Cast SDK; writes `currentTrackIndex` and reads
699
+ // the track list.
700
+ playerQueue.async { self.emitRemoteTrackChangeOnQueue(index: index) }
701
+ }
702
+
703
+ private func emitRemoteTrackChangeOnQueue(index: Int) {
704
+ guard index >= 0, index < self.tracks.count else { return }
705
+ self.currentTrackIndex = index
706
+ let track = self.tracks[index]
707
+ // No engine call happens on this path, so nothing downstream reaches
708
+ // `dispatchTrackChange`; do what it does for the source here.
709
+ self.reclassifyCurrentTrackSource()
710
+ self.trackChangeListeners.forEach { $0(track, Double(index), .autoAdvance, nil) }
711
+ // `currentTrackIndex` just moved — recompute skip capability so the
712
+ // receiver's advance updates canSkipPrevious/Next (skip-back enables
713
+ // once off the first track), same as every local index write.
714
+ self.recomputeCapabilities()
715
+ }
716
+
717
+ /// Hand off current local playback to a newly-connected cast receiver:
718
+ /// load the current queue at the current playhead with the current
719
+ /// play/pause, so playback continues where it was. The local engine is
720
+ /// silenced immediately (after the state read) so the device speaker
721
+ /// never overlaps the receiver. Async — the receiver load is a
722
+ /// round-trip; a failed load leaves local paused (never blasts audio).
723
+ func handoffCurrentPlaybackToCast() {
724
+ // Arrives on main from the Cast SDK. The capture and the local pause are
725
+ // player state, so they run on the queue that owns it — asynchronously,
726
+ // because a synchronous wait here parks main behind whatever the queue is
727
+ // already doing, up to and including a full queue rebuild.
728
+ playerQueue.async { [self] in
729
+ let snapshot = self.buildHandoffSnapshot()
730
+ guard let snapshot else { return }
731
+ self.castEventBridge.seedDeactivationSnapshot(
732
+ trackIndex: snapshot.startIndex,
733
+ positionMs: snapshot.positionMs
734
+ )
735
+ // Chained like every other receiver mirror. This load is absolute, so a
736
+ // delta mirror for a mutation issued just after activation must not
737
+ // complete before it — the load would discard that mutation and leave the
738
+ // receiver permanently diverged from the local queue.
739
+ let previous = self.castMirrorChain
740
+ self.castMirrorChain = Task { [weak self] in
741
+ await previous?.value
742
+ guard let self = self else { return }
743
+ let ids = try? await CastTransportRouter.routeSetQueue(
744
+ tracks: snapshot.items,
745
+ startIndex: snapshot.startIndex,
746
+ startPositionMs: snapshot.positionMs,
747
+ playWhenReady: snapshot.playing
748
+ )
749
+ // A non-nil result means the receiver load succeeded; seed the start
750
+ // track's lockscreen metadata. The current track is then tracked via
751
+ // the receiver's absolute queue index, not these itemIds.
752
+ if ids != nil {
753
+ await self.onPlayerQueue {
754
+ self.castEventBridge.seedStartMetadata(startIndex: snapshot.startIndex)
755
+ }
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ /// Capture the state a receiver handoff replays, and silence the local
762
+ /// engine so the device speaker never overlaps the receiver. Caller must be
763
+ /// on `playerQueue`.
764
+ private func buildHandoffSnapshot() -> HandoffSnapshot? {
765
+ let tracks = self.tracks
766
+ let idx = self.currentTrackIndex
767
+ let positionMs = Int64(max(0, (self.engine?.currentPositionSeconds ?? 0)) * 1000.0)
768
+ // The transport's intent, not the leg's audible state: a leg that is
769
+ // buffering, or mid-crossfade with its outgoing item finished, reads as
770
+ // not-playing while the consumer is still playing — and the receiver would
771
+ // start paused.
772
+ let playing = self.wantsToPlay
773
+ self.engine?.pause()
774
+ guard idx >= 0, idx < tracks.count else { return nil }
775
+ let items = tracks.compactMap { CastMediaItem.from(track: $0) }
776
+ guard items.count == tracks.count else { return nil }
777
+ return HandoffSnapshot(
778
+ items: items, startIndex: idx, positionMs: positionMs, playing: playing
779
+ )
780
+ }
781
+
782
+ private struct HandoffSnapshot {
783
+ let items: [CastMediaItem]
784
+ let startIndex: Int
785
+ let positionMs: Int64
786
+ let playing: Bool
787
+ }
788
+
789
+ /// Return local playback to the receiver's last track + position after
790
+ /// a cast session ends. The local engine kept the mirrored queue
791
+ /// (paused) during cast, so this seeks it to the handback point and
792
+ /// stays PAUSED — the user resumes locally. Never auto-blasts audio out
793
+ /// of the phone on disconnect. A `-1` trackIndex (receiver index never
794
+ /// resolved) is a no-op; local stays where it was.
795
+ func resumeLocalAfterCast(trackIndex: Int, positionMs: Int64) {
796
+ // Arrives on main from the Cast SDK; the rebuild below is player state and
797
+ // must not be waited on from main.
798
+ playerQueue.async {
799
+ guard trackIndex >= 0, trackIndex < self.tracks.count else { return }
800
+ let positionSec = max(0, Double(positionMs) / 1000.0)
801
+ self.currentTrackIndex = trackIndex
802
+ // Seated mid-track from the receiver's position: not over, whatever the
803
+ // local queue had reached before the handoff.
804
+ self.reachedQueueEnd = false
805
+ // Rebuild the local engine from the resumed absolute index. The
806
+ // engine was frozen at the cast-start index for the whole cast
807
+ // session, so its queue no longer matches `tracks[trackIndex...]`;
808
+ // a raw `engine.seek(toIndex: trackIndex)` would index the
809
+ // GaplessEngine's remaining-queue space (not the lib's absolute
810
+ // index) and seat the wrong item, leaving a later skipToIndex /
811
+ // play desynced from `currentTrackIndex`. The rebuild re-seats the
812
+ // current item so transport stays consistent.
813
+ self.performingMutation { self.fullRebuildPlayerQueue() }
814
+ // Seek within the freshly-seated current item to the receiver's last
815
+ // position. Resolved by identity: the installed run reaches behind the
816
+ // playhead on the crossfade engine, so engine-index 0 is a track already
817
+ // played, not the current one. No seek at all beats a seek to the wrong
818
+ // track, so an unresolvable position leaves it where the rebuild seated
819
+ // it.
820
+ if positionSec > 0,
821
+ let engineIdx = self.engineIndex(forQueuePosition: trackIndex) {
822
+ self.engine?.seek(toIndex: engineIdx, position: positionSec)
823
+ }
824
+ // Stay paused — the user resumes locally; never auto-blast the
825
+ // phone speaker on revert.
826
+ self.engine?.pause()
827
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
828
+ self.rescheduleLookahead()
829
+ self.recomputeCapabilities()
830
+ }
831
+ }
832
+
833
+ /// Real end-of-track signal. AVPlayerItemDidPlayToEndTime fires
834
+ /// when an item finishes playback naturally — NOT when
835
+ /// AVQueuePlayer chain-advances through unbuffered items during a
836
+ /// rebuild. The lib uses this distinction to:
837
+ ///
838
+ /// - Natural auto-advance: notification fires → set
839
+ /// `didPlayToEndPending` → `handleCurrentItemDidChange` runs
840
+ /// `matchTrackIndex` resync → `currentTrackIndex` updates to
841
+ /// the auto-advanced position.
842
+ /// - Chain-advance bounce: notification does NOT fire (items
843
+ /// weren't actually playing). `\.currentItem` KVO still fires
844
+ /// from the rebuild's insert sequence, but
845
+ /// `handleCurrentItemDidChange`'s gate sees
846
+ /// `didPlayToEndPending == false` and skips the resync —
847
+ /// `currentTrackIndex` stays at whatever the user mutation
848
+ /// pre-wrote.
849
+ ///
850
+ /// Repeat-track handling: when `repeatModeState == .track`, seek
851
+ /// the just-finished item back to zero + replay. The gapless engine
852
+ /// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
853
+ /// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
854
+ /// player does not advance and `player.currentItem` is still the
855
+ /// finished item when this async block runs — the rewind is
856
+ /// deterministic, and the seek-to-zero produces no `\.currentItem`
857
+ /// change to gate. `didPlayToEndPending` is armed in this branch only
858
+ /// when the player has already advanced past the finished item.
859
+ @objc internal func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
860
+ // Capture the repeat mode as the item ends, before the hop to the owner
861
+ // queue. `setRepeatMode` writes `actionAtItemEnd`, so a toggle landing
862
+ // inside that hop would have the branch below decide against a mode that
863
+ // was not in force when the player acted on the end of the track. This
864
+ // runs on AVFoundation's thread; the read is lock-guarded for it.
865
+ let repeatModeAtEnd = self.repeatModeState
866
+ playerQueue.async { [weak self] in
867
+ guard let self else { return }
868
+ // End-of-track sleep timer: pause at the current track's real end —
869
+ // before the repeat-track rewind or the auto-advance — so the pause
870
+ // lands exactly at the boundary and fires even when the track duration
871
+ // was never known. Under repeat-one this pauses at the first natural end.
872
+ let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
873
+ if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
874
+ // Branch on repeat-track FIRST, separately from the
875
+ // currentItem === item check.
876
+ if repeatModeAtEnd == .track {
877
+ // Repeat-track: rewind the just-finished item + replay. The
878
+ // gapless engine holds the item via `actionAtItemEnd = .none`
879
+ // under `.track`, so the player does not advance and
880
+ // `currentItem` is still the finished item here.
881
+ if let player = self.player {
882
+ // Repeat-one loop = a new milestone playthrough of the same track.
883
+ // The gapless engine fires no track-change / delegate here, so
884
+ // dispatchTrackChange's reset is skipped — reset directly, and
885
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), so
886
+ // milestones re-arm for the new playthrough. (CrossfadeEngine has
887
+ // self.player == nil here and resets via engineDidPlayItemToEnd
888
+ // instead — no double reset.)
889
+ self.milestoneTracker.reset()
890
+ if let item = notification.object as? AVPlayerItem,
891
+ player.currentItem === item {
892
+ item.seek(to: .zero, completionHandler: nil)
893
+ // Replay through the engine so the user's playback speed is
894
+ // restored (a raw AVPlayer.play() resets rate to 1.0) — unless the
895
+ // end-of-track sleep timer just fired, in which case we pause at
896
+ // this natural end instead of looping.
897
+ if !sleepFired.pauseNow { self.engine?.play() }
898
+ } else {
899
+ // The finished item is no longer current, so the player advanced
900
+ // before repeat-track took hold — the mode was toggled as the
901
+ // track ended. There is a real move to reconcile, so arm the
902
+ // resync the `\.currentItem` KVO consumes; without it the index
903
+ // keeps naming the previous track, and `dispatchTrackChange`
904
+ // dedups against the stale id so nothing is announced at all.
905
+ self.didPlayToEndPending = true
906
+ }
907
+ }
908
+ return
909
+ }
910
+ // Repeat-off / repeat-queue: arm the resync gate so
911
+ // `handleCurrentItemDidChange` will run `matchTrackIndex`
912
+ // when the `\.currentItem` KVO fires for the next item.
913
+ self.didPlayToEndPending = true
914
+ let now = DispatchTime.now().uptimeNanoseconds
915
+ self.gaplessGapPrevEndedAtNs = now
916
+ #if DEBUG
917
+ self.gaplessLogLastEndedAtNs = now
918
+ NSLog("[RNQP-GAPLESS] item-ended trackIdx=%d ts_ns=%llu",
919
+ self.currentTrackIndex, now)
920
+ #endif
921
+ }
922
+ }
923
+
924
+ /// New `AVPlayerItem` access-log entries arrive multiple times per
925
+ /// second during streaming (per network transfer chunk). Two
926
+ /// concerns share the handler: the bitrate fallback (re-resolves the
927
+ /// now-playing format while the last emitted one has no bitrate) and
928
+ /// DEBUG-only timeline logging.
929
+ ///
930
+ /// Once a bitrate lands in `lastEmittedFormat`, later fires for the same
931
+ /// item do not re-resolve. The active-item gate (`item ===
932
+ /// self.activeMediaItem`) drops fires from items the engine has already
933
+ /// advanced past; `reresolveNowPlayingFormatForBitrate` bounds the
934
+ /// attempts per item and checks its `nowPlayingFormatRequestGen` token
935
+ /// so a stale resolve cannot overwrite a newer item's format.
936
+ @objc internal func handleNewAccessLogEntry(_ notification: Notification) {
937
+ playerQueue.async { [weak self] in
938
+ guard let self,
939
+ let item = notification.object as? AVPlayerItem else { return }
940
+ // Not gated on codec. The access log reports whatever the player
941
+ // negotiated, and a format that resolved without a bitrate is worth
942
+ // re-reading whatever it is — MP3 is neither the only nor the usual
943
+ // codec a consumer streams.
944
+ if item === self.activeMediaItem,
945
+ self.lastEmittedFormatItemId == ObjectIdentifier(item),
946
+ let format = self.lastEmittedFormat {
947
+ let hasBitrate: Bool = {
948
+ guard let variant = format.bitrate,
949
+ case .second = variant else { return false }
950
+ return true
951
+ }()
952
+ if !hasBitrate {
953
+ self.reresolveNowPlayingFormatForBitrate()
954
+ }
955
+ }
956
+ #if DEBUG
957
+ guard let event = item.accessLog()?.events.last else { return }
958
+ let now = DispatchTime.now().uptimeNanoseconds
959
+ let deltaSinceChangedMs = self.gaplessLogLastChangedAtNs > 0
960
+ ? (now &- self.gaplessLogLastChangedAtNs) / 1_000_000 : 0
961
+ NSLog("[RNQP-GAPLESS] access-log trackIdx=%d stalls=%ld observedBitrate=%lld transferDurationMs=%d numberOfMediaRequests=%ld delta_since_changed_ms=%llu",
962
+ self.currentTrackIndex,
963
+ event.numberOfStalls,
964
+ Int64(event.observedBitrate),
965
+ Int(event.transferDuration * 1000),
966
+ event.numberOfMediaRequests,
967
+ deltaSinceChangedMs)
968
+ #endif
969
+ }
970
+ }
971
+
972
+ #if DEBUG
973
+ /// Error-log entries surface alongside the existing
974
+ /// `AVPlayerItemFailedToPlayToEndTime` notification for a richer
975
+ /// diagnostic record. Compiled out in Release builds.
976
+ @objc internal func handleNewErrorLogEntry(_ notification: Notification) {
977
+ playerQueue.async { [weak self] in
978
+ guard let self,
979
+ let item = notification.object as? AVPlayerItem,
980
+ let log = item.errorLog(),
981
+ let event = log.events.last else { return }
982
+ NSLog("[RNQP-GAPLESS] error-log trackIdx=%d errorDomain=%@ errorCode=%ld errorComment=%@",
983
+ self.currentTrackIndex,
984
+ event.errorDomain,
985
+ event.errorStatusCode,
986
+ event.errorComment ?? "nil")
987
+ }
988
+ }
989
+ #endif
990
+
991
+ @objc internal func handlePlayerItemFailedToPlayToEndTime(_ notification: Notification) {
992
+ playerQueue.async { [weak self] in
993
+ guard let self else { return }
994
+ let underlying = notification.userInfo?[
995
+ AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
996
+ // Mid-play failures share the same dedup + retry path as
997
+ // load failures — both routed through `dispatchErrorOrRetry`.
998
+ // Real-world repro showed 4× identical AVFoundation "Cannot
999
+ // Open" fires from this notification firing repeatedly when
1000
+ // the player is in a wedged state; the dedup gate suppresses
1001
+ // them, the retry path attempts recovery before surfacing.
1002
+ let item = notification.object as? AVPlayerItem ?? self.player?.currentItem
1003
+ self.dispatchErrorOrRetry(item: item, underlying: underlying)
1004
+ }
1005
+ }
1006
+
1007
+ /// Construct a richly-populated `PlaybackError` from an underlying
1008
+ /// `NSError` + the already-mapped standardized code. Centralised so
1009
+ /// every iOS error fire site produces the same shape (sanitised
1010
+ /// `nativeMessage`, consistent `fatal` classifier, etc.). Mirrors
1011
+ /// `PlaybackErrorMapping.kt#buildPlaybackError` on Android.
1012
+ ///
1013
+ /// `nativeDomainOverride` replaces the underlying `NSError.domain`
1014
+ /// on the emitted error — used by lib-defined error categories
1015
+ /// (`PLAYER_ITEM_LOAD_FAILED`, `AUDIO_SESSION_*`,
1016
+ /// `AUDIO_SESSION_INTERRUPTION_*`) that need a stable
1017
+ /// SCREAMING_SNAKE_CASE marker for JS branching. Nil keeps the raw
1018
+ /// underlying domain for callers that prefer the native string.
1019
+ internal func buildPlaybackError(
1020
+ _ underlying: NSError?,
1021
+ mapped: PlaybackErrorCode,
1022
+ queueItemId: String = "",
1023
+ url: String = "",
1024
+ nativeDomainOverride: String? = nil
1025
+ ) -> PlaybackError {
1026
+ let nativeCode = underlying?.code ?? 0
1027
+ let nativeDomain = nativeDomainOverride ?? (underlying?.domain ?? "")
1028
+ let nativeMessage = PlaybackErrorMapping.stripQuery(
1029
+ underlying?.localizedDescription ?? ""
1030
+ )
1031
+ return PlaybackError(
1032
+ code: mapped,
1033
+ message: PlaybackErrorMapping.messageFor(mapped),
1034
+ fatal: !PlaybackErrorMapping.isTransient(mapped),
1035
+ nativeCode: Double(nativeCode),
1036
+ nativeDomain: nativeDomain,
1037
+ nativeMessage: nativeMessage,
1038
+ queueItemId: queueItemId,
1039
+ url: url
1040
+ )
1041
+ }
1042
+ }