react-native-queue-player 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/build.gradle +7 -0
  4. package/android/consumer-rules.pro +72 -3
  5. package/android/src/main/cpp/airplay2_control.c +4 -4
  6. package/android/src/main/cpp/airplay2_control.h +4 -4
  7. package/android/src/main/cpp/airplay2_jni.cpp +26 -22
  8. package/android/src/main/cpp/airplay2_pair.c +7 -7
  9. package/android/src/main/cpp/airplay2_pair.h +2 -2
  10. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  11. package/android/src/main/cpp/airplay2_session.c +2 -2
  12. package/android/src/main/cpp/airplay_jni.cpp +20 -72
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +870 -278
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +23 -12
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  61. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  62. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  63. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  64. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  65. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +23 -12
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +8 -4
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +163 -0
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  114. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  115. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  116. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  117. package/app.plugin.js +17 -1
  118. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  119. package/ios/AVQueueBuilder.swift +120 -85
  120. package/ios/ArtworkLoader.swift +0 -7
  121. package/ios/ArtworkResolver.swift +22 -4
  122. package/ios/AssetReplayGainReader.swift +118 -0
  123. package/ios/AudioSession.swift +67 -16
  124. package/ios/AudioTapProvider.swift +284 -149
  125. package/ios/CarPlayBridge.swift +0 -12
  126. package/ios/CarPlayCoordinator.swift +18 -2
  127. package/ios/CarPlaySceneDelegate.swift +2 -2
  128. package/ios/Cast/AirPlayRouteState.swift +9 -8
  129. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  130. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  131. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  132. package/ios/Cast/Core/CastSession.swift +11 -0
  133. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  134. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  135. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  136. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  137. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  138. package/ios/CastManager.swift +1 -1
  139. package/ios/CrossfadeEngine.swift +660 -226
  140. package/ios/EQTap.swift +20 -17
  141. package/ios/Equalizer.swift +1 -1
  142. package/ios/FLACStreamInfo.swift +65 -0
  143. package/ios/GaplessEngine.swift +57 -30
  144. package/ios/InputGuards.swift +35 -5
  145. package/ios/LookaheadCache.swift +202 -58
  146. package/ios/LookaheadCachePrefetcher.swift +48 -36
  147. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  148. package/ios/NetworkRecoveryPolicy.swift +32 -0
  149. package/ios/NowPlayingFormatExtractor.swift +25 -18
  150. package/ios/NowPlayingInfo.swift +84 -48
  151. package/ios/OutputRouteMonitor.swift +16 -3
  152. package/ios/PendingBrowseRequests.swift +1 -1
  153. package/ios/PlaceholderArtwork.swift +9 -5
  154. package/ios/PlaybackEngine.swift +165 -76
  155. package/ios/PlaybackErrorMapping.swift +3 -3
  156. package/ios/PlaybackModeStateMachine.swift +2 -2
  157. package/ios/PlaybackNetworkMonitor.swift +73 -0
  158. package/ios/PlayerStateDerivation.swift +6 -6
  159. package/ios/QueueMutationArithmetic.swift +25 -0
  160. package/ios/QueueSkipArithmetic.swift +5 -3
  161. package/ios/QueueWindowArithmetic.swift +152 -0
  162. package/ios/ReadThroughServer.swift +283 -0
  163. package/ios/RemoteCommands.swift +44 -4
  164. package/ios/ReplayGainData.swift +22 -3
  165. package/ios/ReplayGainExtractor.swift +70 -27
  166. package/ios/Siri/VoiceDonation.swift +14 -9
  167. package/ios/StreamingBitrateProbe.swift +22 -68
  168. package/ios/SuppliedReplayGain.swift +83 -0
  169. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  170. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  171. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  172. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  173. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  174. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  175. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  176. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  177. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  178. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  179. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  180. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  181. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  182. package/ios/Tests/EngineSwapTests.swift +91 -0
  183. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  184. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  185. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  186. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  187. package/ios/Tests/InputGuardsTests.swift +41 -0
  188. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  189. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  190. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  191. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  192. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  193. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  194. package/ios/Tests/MutationDeferralTests.swift +220 -0
  195. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  196. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  197. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  198. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  199. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  200. package/ios/Tests/PlayerFixtures.swift +48 -0
  201. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  202. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  203. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  204. package/ios/Tests/QueueStateTests.swift +3 -1
  205. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  206. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  207. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  208. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  209. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  210. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  211. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  212. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  213. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  214. package/ios/Tests/SkipIndexTests.swift +32 -2
  215. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  216. package/ios/Tests/StallRecoveryTests.swift +97 -0
  217. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  218. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  219. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  220. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  221. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  222. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  223. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  224. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  225. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  226. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  227. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  228. package/ios/TrackPlayer+Automotive.swift +101 -0
  229. package/ios/TrackPlayer+Cache.swift +232 -0
  230. package/ios/TrackPlayer+Config.swift +291 -0
  231. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  232. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  233. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  234. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  235. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  236. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  237. package/ios/TrackPlayer+Queue.swift +940 -0
  238. package/ios/TrackPlayer+Recovery.swift +196 -0
  239. package/ios/TrackPlayer+Skip.swift +455 -0
  240. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  241. package/ios/TrackPlayer+State.swift +108 -0
  242. package/ios/TrackPlayer+Threading.swift +135 -0
  243. package/ios/TrackPlayer+Transport.swift +267 -0
  244. package/ios/TrackPlayer+Window.swift +195 -0
  245. package/ios/TrackPlayer.swift +305 -4610
  246. package/ios/Visualizer.swift +6 -5
  247. package/ios/tests-harness/Podfile +1 -1
  248. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  249. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  250. package/lib/module/hooks/useActiveTrack.js +29 -22
  251. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  252. package/lib/module/hooks/useCast.js +8 -26
  253. package/lib/module/hooks/useCast.js.map +1 -1
  254. package/lib/module/hooks/useEqualizer.js +19 -12
  255. package/lib/module/hooks/useEqualizer.js.map +1 -1
  256. package/lib/module/hooks/useLookaheadCache.js +3 -7
  257. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  258. package/lib/module/hooks/useQueue.js +66 -19
  259. package/lib/module/hooks/useQueue.js.map +1 -1
  260. package/lib/module/index.js +6 -3
  261. package/lib/module/index.js.map +1 -1
  262. package/lib/module/queueDelta.js +41 -0
  263. package/lib/module/queueDelta.js.map +1 -0
  264. package/lib/module/types.js +28 -5
  265. package/lib/module/types.js.map +1 -1
  266. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  267. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  269. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useCast.d.ts +6 -1
  271. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  272. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  273. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  274. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  275. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  276. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  277. package/lib/typescript/index.d.ts +2 -1
  278. package/lib/typescript/index.d.ts.map +1 -1
  279. package/lib/typescript/queueDelta.d.ts +10 -0
  280. package/lib/typescript/queueDelta.d.ts.map +1 -0
  281. package/lib/typescript/types.d.ts +168 -12
  282. package/lib/typescript/types.d.ts.map +1 -1
  283. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  284. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  285. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  286. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  287. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  288. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  289. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  320. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  321. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  322. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  323. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  324. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  325. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  326. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  327. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  328. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  329. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  330. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  331. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  332. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  333. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  334. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  335. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  336. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  337. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  338. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  339. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  340. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  341. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  342. package/package.json +8 -8
  343. package/src/TrackPlayer.nitro.ts +53 -27
  344. package/src/hooks/useActiveTrack.ts +29 -22
  345. package/src/hooks/useCast.ts +14 -15
  346. package/src/hooks/useEqualizer.ts +19 -12
  347. package/src/hooks/useLookaheadCache.ts +3 -7
  348. package/src/hooks/useQueue.ts +66 -17
  349. package/src/index.ts +13 -3
  350. package/src/queueDelta.ts +41 -0
  351. package/src/types.ts +169 -12
  352. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  353. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
  354. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  355. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  356. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  357. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  358. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  359. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  360. package/ios/MetadataReader.swift +0 -555
  361. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  362. package/ios/Tests/MetadataReaderTests.swift +0 -603
  363. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  364. package/ios/Tests/VoiceDonationTests.swift +0 -26
  365. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  366. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  367. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,968 @@
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
+ if endFired && !pendingUserMutation {
91
+ if let derived = matchTrackIndex(forCurrentItem: self.player?.currentItem) {
92
+ self.currentTrackIndex = derived
93
+ }
94
+ }
95
+ return reason
96
+ }
97
+
98
+ /// Debug-only log of the classifier's verdict for the current item next to
99
+ /// the URL its asset is bound to and its `sourceURL`.
100
+ ///
101
+ /// The verdict is `classifyTrackSource`'s, computed by the caller from the
102
+ /// track URL, whether the item is routed through the read-through server,
103
+ /// and whether the cache holds the whole file. The asset URL decides
104
+ /// nothing: for a routed item it is the server's `127.0.0.1` address
105
+ /// whether the bytes come from disk or the origin. Queries are stripped: a
106
+ /// source URL carries a token in its query for any server that
107
+ /// authenticates that way.
108
+ private func logPlaybackSource(_ classified: TrackSource?) {
109
+ #if DEBUG
110
+ guard let item = self.engine?.currentMediaItem else { return }
111
+ let assetURL = (item.asset as? AVURLAsset)?.url.absoluteString ?? "<non-url asset>"
112
+ let source = item.sourceURL ?? "<none>"
113
+ let origin = classified.map { "\($0)" } ?? "unknown"
114
+ NSLog(
115
+ "[RNQP-DIAG] now-playing from=%@ asset=%@ source=%@",
116
+ origin,
117
+ PlaybackErrorMapping.stripQuery(assetURL),
118
+ PlaybackErrorMapping.stripQuery(source)
119
+ )
120
+ #endif
121
+ }
122
+
123
+ /// Re-derive `currentTrackSource` for whatever `currentTrackIndex` now
124
+ /// points at. Caller is on `playerQueue`.
125
+ internal func reclassifyCurrentTrackSource() {
126
+ let idx = self.currentTrackIndex
127
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
128
+ ? self.tracks[idx] : nil
129
+ let cache = self.lookaheadCache
130
+ // Whether this position's item reads through the server is decided when
131
+ // the item is built and stamped on it, so the installed item is asked
132
+ // first — any item in the run, not only the seated one, so the answer
133
+ // does not depend on the engine having advanced yet. A position with no
134
+ // item installed is answered on the terms `makeItem` will use for it.
135
+ // A disabled cache serves nothing, so nothing is `cached` while it is off.
136
+ let stamped: Bool? = engineIndex(forQueuePosition: idx).flatMap { engineIdx in
137
+ self.engine?.allMediaItems[safe: engineIdx]?.isRoutedThroughReadThroughServer
138
+ }
139
+ let isRouted = self.lookaheadConfig.enabled
140
+ && (stamped ?? (self.routingServer != nil
141
+ && (track.map { AVQueueBuilder.isRoutable($0.url) } ?? false)))
142
+ self.currentTrackSource = TrackPlayer.classifyTrackSource(
143
+ url: track?.url, isRouted: isRouted
144
+ ) { url in
145
+ cache?.isFullyCached(url: url) ?? false
146
+ }
147
+ logPlaybackSource(self.currentTrackSource)
148
+ }
149
+
150
+ /// Fire `trackChangeListeners` for the current `(track, idx,
151
+ /// reason)`, deduped on `lastEmittedQueueItemId` (NOT idx — index
152
+ /// alone would suppress setQueue replacements that keep cur=0 with
153
+ /// a different first track). On a real fire, also resets the
154
+ /// error-dedup state so a new item's identical-coded error fires.
155
+ internal func dispatchTrackChange(reason: TrackChangeReason) {
156
+ let idx = self.currentTrackIndex
157
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
158
+ ? self.tracks[idx] : nil
159
+ let currentItemId: String? = self.queueItemIds[safe: idx]
160
+ self.reclassifyCurrentTrackSource()
161
+ if self.lastEmittedQueueItemId != currentItemId {
162
+ self.lastEmittedQueueItemId = currentItemId
163
+ self.lastErrorQueueItemId = nil
164
+ self.lastErrorCode = nil
165
+ // A real track (re)loaded => no longer at the end of the queue. At true
166
+ // end-of-queue this block is skipped entirely — the last-track id still
167
+ // matches `lastEmittedQueueItemId` — so `reachedQueueEnd` stays set; the
168
+ // `idx >= 0` guard only keeps an explicit clear to index -1 from touching it.
169
+ if idx >= 0 { self.reachedQueueEnd = false }
170
+ // New active track => new milestone playthrough. This is the single
171
+ // deduped track-change point (the crossfade fade-start echo collapses
172
+ // here), so it resets exactly once per real track change.
173
+ self.milestoneTracker.reset()
174
+ // New track => its initial load counts as `buffering` (not `stalled`)
175
+ // until it reaches a playing state, and it starts not-fully-buffered.
176
+ self.hasStartedPlaying = false
177
+ self.recomputeFullyBuffered()
178
+ // Measure the gapless auto-advance silence gap (nil for every
179
+ // other transition) before the emit so it rides on onTrackChange
180
+ // as `transitionGapMs`. The timestamp was captured at the top of
181
+ // handleCurrentItemDidChange, above this method's synchronous lib
182
+ // work; falls back to now() if the snapshot wasn't taken.
183
+ var transitionGapMs: Double? = nil
184
+ if reason == .autoAdvance && self.gaplessGapPrevEndedAtNs > 0 {
185
+ let now = self.gaplessGapMeasuredAtNs > 0
186
+ ? self.gaplessGapMeasuredAtNs
187
+ : DispatchTime.now().uptimeNanoseconds
188
+ let gapNs = now &- self.gaplessGapPrevEndedAtNs
189
+ transitionGapMs = Double(gapNs) / 1_000_000.0
190
+ self.gaplessGapPrevEndedAtNs = 0
191
+ self.gaplessGapMeasuredAtNs = 0
192
+ #if DEBUG
193
+ self.gaplessLogLastChangedAtNs = now
194
+ NSLog("[RNQP-GAPLESS] item-changed trackIdx=%d ts_ns=%llu delta_since_ended_ms=%.3f",
195
+ idx, now, transitionGapMs ?? 0)
196
+ #endif
197
+ }
198
+ // Cross-platform listener-fire order: track-change first so the
199
+ // consumer's track-keyed handlers run before the format-refresh
200
+ // emits null. Matches Android's onMediaItemTransition →
201
+ // refreshNowPlayingFormatForActiveItem ordering.
202
+ trackChangeListeners.forEach { $0(track, Double(idx), reason, transitionGapMs) }
203
+ self.refreshNowPlayingFormatForActiveItem()
204
+ self.nowPlayingInfo.refreshAll(self.nowPlayingSnapshot())
205
+ // Kick the network resolve. The placeholder is already on the
206
+ // lock-screen via refreshAll; the resolver swaps in the real
207
+ // bitmap when the fetch lands. ArtworkResolver's single in-
208
+ // flight + last-URL guard drops a stale fetch if another track
209
+ // change fires before completion.
210
+ self.artworkResolver.resolve(url: track?.artworkUrl) { [weak self] image in
211
+ self?.nowPlayingInfo.applyArtwork(image)
212
+ }
213
+ }
214
+ }
215
+
216
+ /// Classify the active-track playback source.
217
+ ///
218
+ /// `.cached` means the bytes being played are coming off local disk, which
219
+ /// takes two things: the cache holds the track in full, **and** the item is
220
+ /// bound to the read-through server, which is what reads from the cache. A
221
+ /// track sitting complete on disk that the player is not routed through is
222
+ /// still being streamed, and saying otherwise is how this reported `cached`
223
+ /// while every byte came from the origin.
224
+ ///
225
+ /// `nil` when the URL is nil or its scheme is neither `file://` nor
226
+ /// `http(s)://` — `data:`, `blob:` and custom schemes have no contract here.
227
+ internal static func classifyTrackSource(
228
+ url: String?, isRouted: Bool, isUrlCached: (String) -> Bool
229
+ ) -> TrackSource? {
230
+ guard let url = url else { return nil }
231
+ if url.hasPrefix("file://") { return .local }
232
+ let isHttp = url.hasPrefix("http://") || url.hasPrefix("https://")
233
+ guard isHttp else { return nil }
234
+ guard isRouted else { return .streaming }
235
+ return isUrlCached(url) ? .cached : .streaming
236
+ }
237
+
238
+ /// Fires from the `addPeriodicTimeObserver` tick (gapless mode)
239
+ /// or the engine-agnostic `progressFallbackTimer` (crossfade
240
+ /// mode, where `self.player` is nil). Reports current position +
241
+ /// duration + seconds-buffered-ahead-of-position.
242
+ ///
243
+ /// The two paths source position differently: gapless reads off
244
+ /// `self.player.currentItem` directly; crossfade reads off
245
+ /// `engine.currentPositionSeconds` / `currentDurationSeconds` /
246
+ /// `bufferedPositionSeconds` (computed properties on the
247
+ /// engine surface that delegate to whichever leg is leading).
248
+ internal func emitProgressIfSubscribed() {
249
+ guard !progressListeners.isEmpty else { return }
250
+ let position: TimeInterval
251
+ let duration: TimeInterval
252
+ let buffered: TimeInterval
253
+ // Whether the playhead is actively advancing. The throttle applies only
254
+ // while playing (the streaming firehose); a paused player's ticks are
255
+ // discrete (seek / stop / repeat-rewind) and must always deliver so the UI
256
+ // reflects the move — otherwise a paused seek under a >500ms interval is
257
+ // lost until playback resumes.
258
+ let isPlaying: Bool
259
+ if let item = self.player?.currentItem {
260
+ position = CMTimeGetSeconds(item.currentTime())
261
+ let rawDuration = CMTimeGetSeconds(item.duration)
262
+ duration = (rawDuration.isFinite && rawDuration > 0) ? rawDuration : 0.0
263
+ buffered = loadedAheadSeconds(for: item, currentPosition: position)
264
+ isPlaying = (self.player?.rate ?? 0) != 0
265
+ } else if let engine = self.engine {
266
+ // Engine-driven path (CrossfadeEngine): the fallback timer fires
267
+ // unconditionally. While paused, emit ONLY when the playhead moved since
268
+ // the last emit (a seek / stop / repeat-rewind), mirroring the gapless
269
+ // periodic observer which fires once on seek completion even while
270
+ // paused — otherwise the post-stop "position reset to 0" tick never
271
+ // reaches consumers. A steady pause (position unchanged) still skips, so
272
+ // identical tuples aren't re-published every 0.5s.
273
+ position = engine.currentPositionSeconds
274
+ // Qualify Swift.abs: a bare `abs(...)` is ambiguous against Darwin's
275
+ // C `abs` when that's in scope (the library build context), so name
276
+ // the Swift overload explicitly — matches BiquadCoefficients.
277
+ let playheadDelta = Swift.abs(position - self.lastEmittedCrossfadePosition)
278
+ let moved = playheadDelta > 0.001
279
+ guard engine.isPlaying || moved else { return }
280
+ self.lastEmittedCrossfadePosition = position
281
+ duration = engine.currentDurationSeconds
282
+ let bufferedAbs = engine.bufferedPositionSeconds
283
+ buffered = max(0, bufferedAbs - position)
284
+ isPlaying = engine.isPlaying
285
+ } else {
286
+ return
287
+ }
288
+ let safePosition = position.isFinite ? position : 0.0
289
+ let safeDuration = duration.isFinite ? duration : 0.0
290
+ let progress = PlayerProgress(
291
+ position: safePosition, duration: safeDuration, buffered: buffered)
292
+ // Throttle only the playing firehose; paused ticks (discrete seek/stop
293
+ // results) always deliver. The crossfade movement bookkeeping above already
294
+ // ran this tick, and milestones + buffer state are separate calls, so both
295
+ // stay full-rate regardless of the throttle.
296
+ guard !isPlaying || self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else {
297
+ return
298
+ }
299
+ progressListeners.forEach { $0(progress) }
300
+ }
301
+
302
+ /// Advance the milestone tracker off the same periodic tick and emit any
303
+ /// 25/50/75/90% thresholds forward playback just crossed. Runs the
304
+ /// tracker even with no subscribers (so a mid-stream subscribe doesn't
305
+ /// retroactively fire already-passed thresholds); emission is gated on
306
+ /// having listeners.
307
+ internal func processMilestones() {
308
+ // No active track => nothing to attribute a milestone to (mirrors the
309
+ // Android guard; avoids emitting with trackIndex -1 on a stale read
310
+ // during a clear / queue-end transition).
311
+ guard !self.tracks.isEmpty, self.currentTrackIndex >= 0 else { return }
312
+ let position: TimeInterval
313
+ let playerDuration: TimeInterval
314
+ if let item = self.player?.currentItem {
315
+ position = CMTimeGetSeconds(item.currentTime())
316
+ let raw = CMTimeGetSeconds(item.duration)
317
+ playerDuration = (raw.isFinite && raw > 0) ? raw : 0
318
+ } else if let engine = self.engine {
319
+ position = engine.currentPositionSeconds
320
+ playerDuration = engine.currentDurationSeconds
321
+ } else {
322
+ return
323
+ }
324
+ guard position.isFinite else { return }
325
+ let duration = effectiveMilestoneDuration(playerDuration: playerDuration)
326
+ let crossed = milestoneTracker.tick(durationSec: duration, positionSec: position)
327
+ guard !crossed.isEmpty, !milestoneListeners.isEmpty else { return }
328
+ let idx = Double(self.currentTrackIndex)
329
+ for m in crossed {
330
+ milestoneListeners.forEach { $0(Double(m), idx) }
331
+ }
332
+ }
333
+
334
+ /// Player-reported duration when known (> 0), else the consumer-supplied
335
+ /// `TrackItem.duration` (seconds) for the active track, else 0 (no
336
+ /// computable duration → no milestones).
337
+ internal func effectiveMilestoneDuration(playerDuration: TimeInterval) -> TimeInterval {
338
+ if playerDuration > 0 { return playerDuration }
339
+ let idx = self.currentTrackIndex
340
+ if idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 {
341
+ return d
342
+ }
343
+ return 0
344
+ }
345
+
346
+ /// Map an `AVPlayerItem` back to an index in `self.tracks`.
347
+ ///
348
+ /// Lookup is keyed on the lib-generated `queueItemId`
349
+ /// associated-object set by `AVQueueBuilder.makeItem` at
350
+ /// construction. `self.queueItemIds` is a parallel `[String]`
351
+ /// array maintained 1:1 with `self.tracks` at every queue
352
+ /// mutation site; `firstIndex(of:)` resolves the AVPlayerItem's
353
+ /// `queueItemId` to its position in O(N) — N is queue size,
354
+ /// typical < 100 tracks, no memoization warranted.
355
+ ///
356
+ /// queueItemId is unique per construction, so each AVPlayerItem
357
+ /// maps to exactly one queue position even when the consumer
358
+ /// supplies the same `track.url` at multiple positions.
359
+ ///
360
+ /// Returns nil when `item` is nil, when the item lacks a
361
+ /// queueItemId (e.g. constructed outside `AVQueueBuilder` —
362
+ /// shouldn't happen in production), or when the queueItemId
363
+ /// isn't in `self.queueItemIds` (post-mutation desync — also
364
+ /// shouldn't happen, but defensive). The caller leaves
365
+ /// `currentTrackIndex` unchanged on a nil result.
366
+ internal func matchTrackIndex(forCurrentItem item: AVPlayerItem?) -> Int? {
367
+ guard let id = item?.queueItemId else { return nil }
368
+ return self.queueItemIds.firstIndex(of: id)
369
+ }
370
+
371
+ /// Translate a queue position into the active engine's own index space by
372
+ /// item identity. An engine indexes within the slice it was handed, so a
373
+ /// queue position is only usable after this lookup.
374
+ ///
375
+ /// `nil` means the track is not materialised in the engine right now. That
376
+ /// is a rebuild signal, not an error — the caller reinstalls the queue at
377
+ /// the wanted position rather than issuing a seek the engine would reject.
378
+ internal func engineIndex(forQueuePosition position: Int) -> Int? {
379
+ guard let engine = self.engine,
380
+ position >= 0, position < self.queueItemIds.count else { return nil }
381
+ let id = self.queueItemIds[position]
382
+ return engine.allMediaItems.firstIndex { $0.queueItemId == id }
383
+ }
384
+
385
+ /// Compute "seconds buffered ahead of position" — consumers use
386
+ /// this for the "buffered track" overlay on a progress bar. Takes
387
+ /// the end of the last loaded `CMTimeRange`, subtracts the current
388
+ /// position, and clamps to zero. Returns 0 when no ranges are
389
+ /// loaded or when the subtraction is non-finite.
390
+ private func loadedAheadSeconds(
391
+ for item: AVPlayerItem, currentPosition: Double
392
+ ) -> Double {
393
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return 0 }
394
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
395
+ guard end.isFinite, currentPosition.isFinite else { return 0 }
396
+ return max(0, end - currentPosition)
397
+ }
398
+
399
+ /// Arm stall recovery. AVPlayer reports the underrun and then does nothing:
400
+ /// with `automaticallyWaitsToMinimizeStalling` off it will not resume when
401
+ /// the buffer refills, so `recomputeBufferState` re-issues play once it does.
402
+ ///
403
+ /// Gated on transport intent — a stall that arrives while the user is paused
404
+ /// must not arm a resume that fires later and starts audio they did not ask
405
+ /// for.
406
+ @objc internal func handlePlaybackStalled(_ notification: Notification) {
407
+ // The observer is registered for every item in the process; only the
408
+ // item being heard can stall playback. A crossfade standby leg stalling
409
+ // in preroll, or another player's item, must not arm a resume.
410
+ let stalled = notification.object as? AVPlayerItem
411
+ playerQueue.async {
412
+ guard self.wantsToPlay, let stalled, stalled === self.activeMediaItem else { return }
413
+ self.isRecoveringFromStall = true
414
+ // The buffer can refill before this notification is delivered. The
415
+ // keep-up flag only reports on change, so waiting for the next edge
416
+ // would wait forever — recompute now and let it settle the resume.
417
+ self.recomputeBufferState()
418
+ }
419
+ }
420
+
421
+ /// Recompute the current buffer state from the active engine item, advance
422
+ /// the per-track buffered high-water mark, and fire `onBufferStateChange`
423
+ /// when the discrete state actually changes. Runs on the buffer KVO, each
424
+ /// progress tick, the transport methods, and at track change.
425
+ ///
426
+ /// `.full` = keeps up (`playbackLikelyToKeepUp` / `playbackBufferFull`).
427
+ /// A not-full loaded item is `.stalled` when playback had started and is
428
+ /// intended (a mid-play rebuffer) or `.buffering` otherwise (initial load /
429
+ /// loading while paused). `.empty` means nothing is loaded to play.
430
+ internal func recomputeBufferState() {
431
+ let item = self.engine?.currentMediaItem
432
+ // A newly seated item announces its buffer state fresh. Drop the emitted
433
+ // baseline when the active item's queueItemId changes so the first recompute
434
+ // for the new item emits even if it lands on the same discrete state the
435
+ // previous item ended on — a `.full` queue reloaded to another `.full`
436
+ // local track otherwise catches no intermediate sample and emits nothing.
437
+ let itemId = item?.queueItemId
438
+ if itemId != self.lastBufferStateQueueItemId {
439
+ self.lastBufferStateQueueItemId = itemId
440
+ self.lastEmittedBufferState = nil
441
+ }
442
+ let computed: BufferState
443
+ if let item {
444
+ if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
445
+ computed = .full
446
+ } else {
447
+ computed = (self.hasStartedPlaying && self.wantsToPlay) ? .stalled : .buffering
448
+ }
449
+ } else {
450
+ computed = .empty
451
+ }
452
+ self.currentBufferState = computed
453
+ // The buffer has recovered and the user still wants playback: nothing else
454
+ // will restart it. Not while the session is interrupted — the refill can
455
+ // land mid-call, and the interruption's `.ended` is what resumes then.
456
+ if self.isRecoveringFromStall, computed == .full, self.wantsToPlay, !self.isInterrupted {
457
+ self.isRecoveringFromStall = false
458
+ self.beginPlayback()
459
+ }
460
+ guard computed != self.lastEmittedBufferState else { return }
461
+ self.lastEmittedBufferState = computed
462
+ bufferStateListeners.forEach { $0(computed) }
463
+ }
464
+
465
+ /// The whole track has downloaded — the loaded range has reached the track's
466
+ /// duration. When the player can't report a duration (e.g. a transcoded
467
+ /// stream) it falls back to the consumer-supplied `track.duration`, the same
468
+ /// fallback milestones use. A live / indefinite source never completes.
469
+ /// (`isPlaybackBufferFull` is deliberately NOT used — it means the fixed
470
+ /// buffer is full, not that the whole track has downloaded.)
471
+ private func computeFullyBuffered() -> Bool {
472
+ guard let item = self.engine?.currentMediaItem else { return false }
473
+ if item.duration.isIndefinite { return false }
474
+ // A local file or a fully-cached source is entirely on disk — fully
475
+ // available regardless of the playback buffer.
476
+ if self.currentTrackSource == .local || self.currentTrackSource == .cached { return true }
477
+ var dur = CMTimeGetSeconds(item.duration)
478
+ if !dur.isFinite || dur <= 0 {
479
+ let idx = self.currentTrackIndex
480
+ guard idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 else {
481
+ return false
482
+ }
483
+ dur = d
484
+ }
485
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return false }
486
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
487
+ return end.isFinite && end >= dur - Self.fullyBufferedEpsilonSeconds
488
+ }
489
+
490
+ /// Recompute fully-buffered and fire `onFullyBufferedChange` on a change.
491
+ /// Runs on the buffer-full KVO, the progress tick (the loaded range grows),
492
+ /// and at track change.
493
+ internal func recomputeFullyBuffered() {
494
+ let computed = computeFullyBuffered()
495
+ self.currentFullyBuffered = computed
496
+ guard computed != self.lastEmittedFullyBuffered else { return }
497
+ self.lastEmittedFullyBuffered = computed
498
+ fullyBufferedListeners.forEach { $0(computed) }
499
+ }
500
+
501
+ /// Cap `target` at the current buffered extent when `clampSeekToBuffered`
502
+ /// is enabled and the target is past what has buffered. A local or fully-
503
+ /// buffered track reports `buffered >= duration >= target`, so nothing is
504
+ /// clamped and a seek reaches the end freely; a partly-buffered stream caps
505
+ /// at the downloaded edge. Reads the live buffered position (not a tracked
506
+ /// mark) so a seek-back that evicted the ahead-buffer clamps to what's there.
507
+ internal func clampToBufferedIfEnabled(_ target: Double) -> Double {
508
+ guard self.config.clampSeekToBuffered == true else { return target }
509
+ guard let buffered = self.engine?.bufferedPositionSeconds, buffered.isFinite, buffered > 0 else {
510
+ return target
511
+ }
512
+ return target > buffered ? buffered : target
513
+ }
514
+
515
+ /// Translate `AVPlayer.timeControlStatus` into our `PlayerState`
516
+ /// enum and fire `onStateChange` when the value actually changed.
517
+ /// Consumes `pendingStateChangeReason` (set by transport methods
518
+ /// before they call `play`/`pause`/`stop`, or by the AudioSession
519
+ /// handlers for interruption/route-change events) so user-
520
+ /// initiated transitions are labelled correctly while purely
521
+ /// system-internal ones default to `.system`.
522
+ ///
523
+ /// `.buffering` is a system-internal intermediate state — it
524
+ /// shows up between `.paused` → `.playing` while AVPlayer fills
525
+ /// its buffer. We deliberately do NOT consume the pending reason
526
+ /// for `.buffering` so the reason survives to the terminal
527
+ /// `.playing` emit. Otherwise a play() after interruption would
528
+ /// label buffering `.interruption` and the actual `.playing` as
529
+ /// `.system`, which consumers won't expect.
530
+ internal func emitStateChangeIfChanged() {
531
+ guard let engine = self.engine else { return }
532
+ let computed = PlayerStateDerivation.translate(
533
+ status: engine.timeControlStatus,
534
+ currentIndex: self.currentTrackIndex,
535
+ hasCurrentItem: engine.currentMediaItem != nil,
536
+ reachedQueueEnd: self.reachedQueueEnd
537
+ )
538
+ emitState(computed, reason: reasonForComputedState(computed))
539
+ }
540
+
541
+ /// Reason for a derived `PlayerState`, shared by the gapless KVO
542
+ /// (`emitStateChangeIfChanged`) and the crossfade delegate
543
+ /// (`engineStateMaybeChanged`) so both label states identically.
544
+ /// `.buffering` is intermediate — pass through as `.system` and leave the
545
+ /// pending reason for the terminal emit. `.ended` is always end-of-queue —
546
+ /// stamp `.queueEnd` to match `detectQueueEnd` / `enginePlaybackEnded`.
547
+ internal func reasonForComputedState(_ computed: PlayerState) -> StateChangeReason {
548
+ if computed == .buffering {
549
+ return .system
550
+ }
551
+ if computed == .ended {
552
+ self.pendingStateChangeReason = nil
553
+ return .queueEnd
554
+ }
555
+ let reason = self.pendingStateChangeReason ?? .system
556
+ self.pendingStateChangeReason = nil
557
+ return reason
558
+ }
559
+
560
+ /// Bypass the timeControlStatus translation and emit a specific
561
+ /// state directly (used for `.ended` / `.error` which AVPlayer
562
+ /// doesn't model as timeControlStatus).
563
+ internal func emitState(_ state: PlayerState, reason: StateChangeReason) {
564
+ // Playback (re)starting clears `reachedQueueEnd` — do this BEFORE the
565
+ // dedup guard so a redundant `.playing` re-emit still clears it.
566
+ if state == .playing { self.reachedQueueEnd = false }
567
+ guard state != self.lastReportedState else { return }
568
+ self.lastReportedState = state
569
+ // Once the track actually plays, a later not-full buffer is a mid-playback
570
+ // `stalled`, not the initial `buffering` load.
571
+ if state == .playing { self.hasStartedPlaying = true }
572
+ self.stateChangeListeners.forEach { $0(state, reason) }
573
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
574
+ }
575
+
576
+ /// Internal entry point for `CastEventBridge` to fan receiver-driven
577
+ /// state into the JS-facing event surface. Goes through the same
578
+ /// `emitState` dedup chokepoint as local-engine transitions, so JS
579
+ /// sees one event per real receiver transition regardless of source.
580
+ func emitRemoteState(_ state: PlayerState, reason: StateChangeReason) {
581
+ // Arrives on main from the Cast SDK's listeners; `emitState` writes the
582
+ // reported-state fields that the local path owns.
583
+ playerQueue.async { self.emitState(state, reason: reason) }
584
+ }
585
+
586
+ /// Fan a cast receiver's position update to JS `onProgress` so the
587
+ /// consumer's player screen (progress bar / position / duration) tracks
588
+ /// the receiver while a remote session is active. The receiver reports
589
+ /// milliseconds; `PlayerProgress` is in seconds. Buffered arrives as an
590
+ /// absolute receiver position, so it's reduced to "ahead of position"
591
+ /// to match the local path's semantics. The local now-playing / format
592
+ /// path is untouched — the local engine is silent during cast.
593
+ func emitRemoteProgress(positionMs: Int64, durationMs: Int64, bufferedMs: Int64) {
594
+ // Arrives on main from the Cast SDK; the throttle it shares with the local
595
+ // tick is player state.
596
+ playerQueue.async {
597
+ self.emitRemoteProgressOnQueue(
598
+ positionMs: positionMs, durationMs: durationMs, bufferedMs: bufferedMs)
599
+ }
600
+ }
601
+
602
+ private func emitRemoteProgressOnQueue(
603
+ positionMs: Int64, durationMs: Int64, bufferedMs: Int64
604
+ ) {
605
+ guard !progressListeners.isEmpty else { return }
606
+ // Same throttle as the local tick, shared instance — a consumer's
607
+ // configured cadence caps cast progress too (the receiver pushes at its own
608
+ // rate, so the effective cadence is min(receiver rate, configured interval)).
609
+ guard self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else { return }
610
+ let position = TimeInterval(max(0, positionMs)) / 1000.0
611
+ let duration = TimeInterval(max(0, durationMs)) / 1000.0
612
+ let buffered = TimeInterval(max(0, bufferedMs - positionMs)) / 1000.0
613
+ let progress = PlayerProgress(position: position, duration: duration, buffered: buffered)
614
+ progressListeners.forEach { $0(progress) }
615
+ }
616
+
617
+ /// Fan a cast receiver's track advance to JS `onTrackChange` and update
618
+ /// the authoritative `currentTrackIndex` so the consumer's player screen
619
+ /// follows the receiver's current track. Mirrors the local dispatch's
620
+ /// listener fire without the local-engine now-playing refresh (the cast
621
+ /// lockscreen is driven separately via `CastNowPlayingController`).
622
+ /// Called on the main thread from `CastEventBridge`, like `emitRemoteState`.
623
+ func emitRemoteTrackChange(index: Int) {
624
+ // Arrives on main from the Cast SDK; writes `currentTrackIndex` and reads
625
+ // the track list.
626
+ playerQueue.async { self.emitRemoteTrackChangeOnQueue(index: index) }
627
+ }
628
+
629
+ private func emitRemoteTrackChangeOnQueue(index: Int) {
630
+ guard index >= 0, index < self.tracks.count else { return }
631
+ self.currentTrackIndex = index
632
+ let track = self.tracks[index]
633
+ // No engine call happens on this path, so nothing downstream reaches
634
+ // `dispatchTrackChange`; do what it does for the source here.
635
+ self.reclassifyCurrentTrackSource()
636
+ self.trackChangeListeners.forEach { $0(track, Double(index), .autoAdvance, nil) }
637
+ // `currentTrackIndex` just moved — recompute skip capability so the
638
+ // receiver's advance updates canSkipPrevious/Next (skip-back enables
639
+ // once off the first track), same as every local index write.
640
+ self.recomputeCapabilities()
641
+ }
642
+
643
+ /// Hand off current local playback to a newly-connected cast receiver:
644
+ /// load the current queue at the current playhead with the current
645
+ /// play/pause, so playback continues where it was. The local engine is
646
+ /// silenced immediately (after the state read) so the device speaker
647
+ /// never overlaps the receiver. Async — the receiver load is a
648
+ /// round-trip; a failed load leaves local paused (never blasts audio).
649
+ func handoffCurrentPlaybackToCast() {
650
+ // Arrives on main from the Cast SDK. The capture and the local pause are
651
+ // player state, so they run on the queue that owns it — asynchronously,
652
+ // because a synchronous wait here parks main behind whatever the queue is
653
+ // already doing, up to and including a full queue rebuild.
654
+ playerQueue.async { [self] in
655
+ let snapshot = self.buildHandoffSnapshot()
656
+ guard let snapshot else { return }
657
+ self.castEventBridge.seedDeactivationSnapshot(
658
+ trackIndex: snapshot.startIndex,
659
+ positionMs: snapshot.positionMs
660
+ )
661
+ // Chained like every other receiver mirror. This load is absolute, so a
662
+ // delta mirror for a mutation issued just after activation must not
663
+ // complete before it — the load would discard that mutation and leave the
664
+ // receiver permanently diverged from the local queue.
665
+ let previous = self.castMirrorChain
666
+ self.castMirrorChain = Task { [weak self] in
667
+ await previous?.value
668
+ guard let self = self else { return }
669
+ let ids = try? await CastTransportRouter.routeSetQueue(
670
+ tracks: snapshot.items,
671
+ startIndex: snapshot.startIndex,
672
+ startPositionMs: snapshot.positionMs,
673
+ playWhenReady: snapshot.playing
674
+ )
675
+ // A non-nil result means the receiver load succeeded; seed the start
676
+ // track's lockscreen metadata. The current track is then tracked via
677
+ // the receiver's absolute queue index, not these itemIds.
678
+ if ids != nil {
679
+ await self.onPlayerQueue {
680
+ self.castEventBridge.seedStartMetadata(startIndex: snapshot.startIndex)
681
+ }
682
+ }
683
+ }
684
+ }
685
+ }
686
+
687
+ /// Capture the state a receiver handoff replays, and silence the local
688
+ /// engine so the device speaker never overlaps the receiver. Caller must be
689
+ /// on `playerQueue`.
690
+ private func buildHandoffSnapshot() -> HandoffSnapshot? {
691
+ let tracks = self.tracks
692
+ let idx = self.currentTrackIndex
693
+ let positionMs = Int64(max(0, (self.engine?.currentPositionSeconds ?? 0)) * 1000.0)
694
+ // The transport's intent, not the leg's audible state: a leg that is
695
+ // buffering, or mid-crossfade with its outgoing item finished, reads as
696
+ // not-playing while the consumer is still playing — and the receiver would
697
+ // start paused.
698
+ let playing = self.wantsToPlay
699
+ self.engine?.pause()
700
+ guard idx >= 0, idx < tracks.count else { return nil }
701
+ let items = tracks.compactMap { CastMediaItem.from(track: $0) }
702
+ guard items.count == tracks.count else { return nil }
703
+ return HandoffSnapshot(
704
+ items: items, startIndex: idx, positionMs: positionMs, playing: playing
705
+ )
706
+ }
707
+
708
+ private struct HandoffSnapshot {
709
+ let items: [CastMediaItem]
710
+ let startIndex: Int
711
+ let positionMs: Int64
712
+ let playing: Bool
713
+ }
714
+
715
+ /// Return local playback to the receiver's last track + position after
716
+ /// a cast session ends. The local engine kept the mirrored queue
717
+ /// (paused) during cast, so this seeks it to the handback point and
718
+ /// stays PAUSED — the user resumes locally. Never auto-blasts audio out
719
+ /// of the phone on disconnect. A `-1` trackIndex (receiver index never
720
+ /// resolved) is a no-op; local stays where it was.
721
+ func resumeLocalAfterCast(trackIndex: Int, positionMs: Int64) {
722
+ // Arrives on main from the Cast SDK; the rebuild below is player state and
723
+ // must not be waited on from main.
724
+ playerQueue.async {
725
+ guard trackIndex >= 0, trackIndex < self.tracks.count else { return }
726
+ let positionSec = max(0, Double(positionMs) / 1000.0)
727
+ self.currentTrackIndex = trackIndex
728
+ // Seated mid-track from the receiver's position: not over, whatever the
729
+ // local queue had reached before the handoff.
730
+ self.reachedQueueEnd = false
731
+ // Rebuild the local engine from the resumed absolute index. The
732
+ // engine was frozen at the cast-start index for the whole cast
733
+ // session, so its queue no longer matches `tracks[trackIndex...]`;
734
+ // a raw `engine.seek(toIndex: trackIndex)` would index the
735
+ // GaplessEngine's remaining-queue space (not the lib's absolute
736
+ // index) and seat the wrong item, leaving a later skipToIndex /
737
+ // play desynced from `currentTrackIndex`. The rebuild re-seats the
738
+ // current item so transport stays consistent.
739
+ self.performingMutation { self.fullRebuildPlayerQueue() }
740
+ // Seek within the freshly-seated current item to the receiver's last
741
+ // position. Resolved by identity: the installed run reaches behind the
742
+ // playhead on the crossfade engine, so engine-index 0 is a track already
743
+ // played, not the current one. No seek at all beats a seek to the wrong
744
+ // track, so an unresolvable position leaves it where the rebuild seated
745
+ // it.
746
+ if positionSec > 0,
747
+ let engineIdx = self.engineIndex(forQueuePosition: trackIndex) {
748
+ self.engine?.seek(toIndex: engineIdx, position: positionSec)
749
+ }
750
+ // Stay paused — the user resumes locally; never auto-blast the
751
+ // phone speaker on revert.
752
+ self.engine?.pause()
753
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
754
+ self.rescheduleLookahead()
755
+ self.recomputeCapabilities()
756
+ }
757
+ }
758
+
759
+ /// Real end-of-track signal. AVPlayerItemDidPlayToEndTime fires
760
+ /// when an item finishes playback naturally — NOT when
761
+ /// AVQueuePlayer chain-advances through unbuffered items during a
762
+ /// rebuild. The lib uses this distinction to:
763
+ ///
764
+ /// - Natural auto-advance: notification fires → set
765
+ /// `didPlayToEndPending` → `handleCurrentItemDidChange` runs
766
+ /// `matchTrackIndex` resync → `currentTrackIndex` updates to
767
+ /// the auto-advanced position.
768
+ /// - Chain-advance bounce: notification does NOT fire (items
769
+ /// weren't actually playing). `\.currentItem` KVO still fires
770
+ /// from the rebuild's insert sequence, but
771
+ /// `handleCurrentItemDidChange`'s gate sees
772
+ /// `didPlayToEndPending == false` and skips the resync —
773
+ /// `currentTrackIndex` stays at whatever the user mutation
774
+ /// pre-wrote.
775
+ ///
776
+ /// Repeat-track handling: when `repeatModeState == .track`, seek
777
+ /// the just-finished item back to zero + replay. The gapless engine
778
+ /// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
779
+ /// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
780
+ /// player does not advance and `player.currentItem` is still the
781
+ /// finished item when this async block runs — the rewind is
782
+ /// deterministic, and the seek-to-zero produces no `\.currentItem`
783
+ /// change to gate. `didPlayToEndPending` is armed in this branch only
784
+ /// when the player has already advanced past the finished item.
785
+ @objc internal func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
786
+ // Capture the repeat mode as the item ends, before the hop to the owner
787
+ // queue. `setRepeatMode` writes `actionAtItemEnd`, so a toggle landing
788
+ // inside that hop would have the branch below decide against a mode that
789
+ // was not in force when the player acted on the end of the track. This
790
+ // runs on AVFoundation's thread; the read is lock-guarded for it.
791
+ let repeatModeAtEnd = self.repeatModeState
792
+ playerQueue.async { [weak self] in
793
+ guard let self else { return }
794
+ // End-of-track sleep timer: pause at the current track's real end —
795
+ // before the repeat-track rewind or the auto-advance — so the pause
796
+ // lands exactly at the boundary and fires even when the track duration
797
+ // was never known. Under repeat-one this pauses at the first natural end.
798
+ let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
799
+ if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
800
+ // Branch on repeat-track FIRST, separately from the
801
+ // currentItem === item check.
802
+ if repeatModeAtEnd == .track {
803
+ // Repeat-track: rewind the just-finished item + replay. The
804
+ // gapless engine holds the item via `actionAtItemEnd = .none`
805
+ // under `.track`, so the player does not advance and
806
+ // `currentItem` is still the finished item here.
807
+ if let player = self.player {
808
+ // Repeat-one loop = a new milestone playthrough of the same track.
809
+ // The gapless engine fires no track-change / delegate here, so
810
+ // dispatchTrackChange's reset is skipped — reset directly, and
811
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), so
812
+ // milestones re-arm for the new playthrough. (CrossfadeEngine has
813
+ // self.player == nil here and resets via engineDidPlayItemToEnd
814
+ // instead — no double reset.)
815
+ self.milestoneTracker.reset()
816
+ if let item = notification.object as? AVPlayerItem,
817
+ player.currentItem === item {
818
+ item.seek(to: .zero, completionHandler: nil)
819
+ // Replay through the engine so the user's playback speed is
820
+ // restored (a raw AVPlayer.play() resets rate to 1.0) — unless the
821
+ // end-of-track sleep timer just fired, in which case we pause at
822
+ // this natural end instead of looping.
823
+ if !sleepFired.pauseNow { self.engine?.play() }
824
+ } else {
825
+ // The finished item is no longer current, so the player advanced
826
+ // before repeat-track took hold — the mode was toggled as the
827
+ // track ended. There is a real move to reconcile, so arm the
828
+ // resync the `\.currentItem` KVO consumes; without it the index
829
+ // keeps naming the previous track, and `dispatchTrackChange`
830
+ // dedups against the stale id so nothing is announced at all.
831
+ self.didPlayToEndPending = true
832
+ }
833
+ }
834
+ return
835
+ }
836
+ // Repeat-off / repeat-queue: arm the resync gate so
837
+ // `handleCurrentItemDidChange` will run `matchTrackIndex`
838
+ // when the `\.currentItem` KVO fires for the next item.
839
+ self.didPlayToEndPending = true
840
+ let now = DispatchTime.now().uptimeNanoseconds
841
+ self.gaplessGapPrevEndedAtNs = now
842
+ #if DEBUG
843
+ self.gaplessLogLastEndedAtNs = now
844
+ NSLog("[RNQP-GAPLESS] item-ended trackIdx=%d ts_ns=%llu",
845
+ self.currentTrackIndex, now)
846
+ #endif
847
+ }
848
+ }
849
+
850
+ /// New `AVPlayerItem` access-log entries arrive multiple times per
851
+ /// second during streaming (per network transfer chunk). Two
852
+ /// concerns share the handler: the bitrate fallback (re-resolves the
853
+ /// now-playing format while the last emitted one has no bitrate) and
854
+ /// DEBUG-only timeline logging.
855
+ ///
856
+ /// Once a bitrate lands in `lastEmittedFormat`, later fires for the same
857
+ /// item do not re-resolve. The active-item gate (`item ===
858
+ /// self.activeMediaItem`) drops fires from items the engine has already
859
+ /// advanced past; `reresolveNowPlayingFormatForBitrate` bounds the
860
+ /// attempts per item and checks its `nowPlayingFormatRequestGen` token
861
+ /// so a stale resolve cannot overwrite a newer item's format.
862
+ @objc internal func handleNewAccessLogEntry(_ notification: Notification) {
863
+ playerQueue.async { [weak self] in
864
+ guard let self,
865
+ let item = notification.object as? AVPlayerItem else { return }
866
+ // Not gated on codec. The access log reports whatever the player
867
+ // negotiated, and a format that resolved without a bitrate is worth
868
+ // re-reading whatever it is — MP3 is neither the only nor the usual
869
+ // codec a consumer streams.
870
+ if item === self.activeMediaItem,
871
+ self.lastEmittedFormatItemId == ObjectIdentifier(item),
872
+ let format = self.lastEmittedFormat {
873
+ let hasBitrate: Bool = {
874
+ guard let variant = format.bitrate,
875
+ case .second = variant else { return false }
876
+ return true
877
+ }()
878
+ if !hasBitrate {
879
+ self.reresolveNowPlayingFormatForBitrate()
880
+ }
881
+ }
882
+ #if DEBUG
883
+ guard let event = item.accessLog()?.events.last else { return }
884
+ let now = DispatchTime.now().uptimeNanoseconds
885
+ let deltaSinceChangedMs = self.gaplessLogLastChangedAtNs > 0
886
+ ? (now &- self.gaplessLogLastChangedAtNs) / 1_000_000 : 0
887
+ NSLog("[RNQP-GAPLESS] access-log trackIdx=%d stalls=%ld observedBitrate=%lld transferDurationMs=%d numberOfMediaRequests=%ld delta_since_changed_ms=%llu",
888
+ self.currentTrackIndex,
889
+ event.numberOfStalls,
890
+ Int64(event.observedBitrate),
891
+ Int(event.transferDuration * 1000),
892
+ event.numberOfMediaRequests,
893
+ deltaSinceChangedMs)
894
+ #endif
895
+ }
896
+ }
897
+
898
+ #if DEBUG
899
+ /// Error-log entries surface alongside the existing
900
+ /// `AVPlayerItemFailedToPlayToEndTime` notification for a richer
901
+ /// diagnostic record. Compiled out in Release builds.
902
+ @objc internal func handleNewErrorLogEntry(_ notification: Notification) {
903
+ playerQueue.async { [weak self] in
904
+ guard let self,
905
+ let item = notification.object as? AVPlayerItem,
906
+ let log = item.errorLog(),
907
+ let event = log.events.last else { return }
908
+ NSLog("[RNQP-GAPLESS] error-log trackIdx=%d errorDomain=%@ errorCode=%ld errorComment=%@",
909
+ self.currentTrackIndex,
910
+ event.errorDomain,
911
+ event.errorStatusCode,
912
+ event.errorComment ?? "nil")
913
+ }
914
+ }
915
+ #endif
916
+
917
+ @objc internal func handlePlayerItemFailedToPlayToEndTime(_ notification: Notification) {
918
+ playerQueue.async { [weak self] in
919
+ guard let self else { return }
920
+ let underlying = notification.userInfo?[
921
+ AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
922
+ // Mid-play failures share the same dedup + retry path as
923
+ // load failures — both routed through `dispatchErrorOrRetry`.
924
+ // Real-world repro showed 4× identical AVFoundation "Cannot
925
+ // Open" fires from this notification firing repeatedly when
926
+ // the player is in a wedged state; the dedup gate suppresses
927
+ // them, the retry path attempts recovery before surfacing.
928
+ let item = notification.object as? AVPlayerItem ?? self.player?.currentItem
929
+ self.dispatchErrorOrRetry(item: item, underlying: underlying)
930
+ }
931
+ }
932
+
933
+ /// Construct a richly-populated `PlaybackError` from an underlying
934
+ /// `NSError` + the already-mapped standardized code. Centralised so
935
+ /// every iOS error fire site produces the same shape (sanitised
936
+ /// `nativeMessage`, consistent `fatal` classifier, etc.). Mirrors
937
+ /// `PlaybackErrorMapping.kt#buildPlaybackError` on Android.
938
+ ///
939
+ /// `nativeDomainOverride` replaces the underlying `NSError.domain`
940
+ /// on the emitted error — used by lib-defined error categories
941
+ /// (`PLAYER_ITEM_LOAD_FAILED`, `AUDIO_SESSION_*`,
942
+ /// `AUDIO_SESSION_INTERRUPTION_*`) that need a stable
943
+ /// SCREAMING_SNAKE_CASE marker for JS branching. Nil keeps the raw
944
+ /// underlying domain for callers that prefer the native string.
945
+ internal func buildPlaybackError(
946
+ _ underlying: NSError?,
947
+ mapped: PlaybackErrorCode,
948
+ queueItemId: String = "",
949
+ url: String = "",
950
+ nativeDomainOverride: String? = nil
951
+ ) -> PlaybackError {
952
+ let nativeCode = underlying?.code ?? 0
953
+ let nativeDomain = nativeDomainOverride ?? (underlying?.domain ?? "")
954
+ let nativeMessage = PlaybackErrorMapping.stripQuery(
955
+ underlying?.localizedDescription ?? ""
956
+ )
957
+ return PlaybackError(
958
+ code: mapped,
959
+ message: PlaybackErrorMapping.messageFor(mapped),
960
+ fatal: !PlaybackErrorMapping.isTransient(mapped),
961
+ nativeCode: Double(nativeCode),
962
+ nativeDomain: nativeDomain,
963
+ nativeMessage: nativeMessage,
964
+ queueItemId: queueItemId,
965
+ url: url
966
+ )
967
+ }
968
+ }