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
@@ -222,14 +222,10 @@ class Visualizer : HybridVisualizerSpec() {
222
222
  * fftSize=2048 should expect proportionally higher GC pressure on
223
223
  * the audio render thread.
224
224
  *
225
- * Write each float directly via `bb.putFloat()` advancing bb's
226
- * own position. The derived `bb.asFloatBuffer().put(values)` form
227
- * appears to write to bb's underlying storage on JVM but does not
228
- * propagate the bytes through `ArrayBuffer.wrap` on Android's
229
- * emulator backend — observed empirically: post-FFT magnitudes
230
- * surface as all-zero on the JS side, even though `bb.getFloat(0)`
231
- * reads back the right value on the Kotlin side. The explicit
232
- * putFloat loop sidesteps the whole class of issue.
225
+ * Writes with `putFloat` rather than `bb.asFloatBuffer().put(values)`:
226
+ * the view form delivered all-zero buffers to JS through
227
+ * `ArrayBuffer.wrap` while `bb.getFloat(0)` read back correctly in
228
+ * Kotlin. Cause not established.
233
229
  */
234
230
  private fun floatArrayToArrayBuffer(values: FloatArray): ArrayBuffer {
235
231
  val bb = ByteBuffer.allocateDirect(values.size * 4).order(ByteOrder.nativeOrder())
@@ -6,9 +6,10 @@ package com.margelo.nitro.queueplayer.cast
6
6
  * `ChromecastBackend` for Cast, future DLNA / Sonos similarly. The
7
7
  * core module never knows which backends are present.
8
8
  *
9
- * The interface is intentionally tiny. Anything richer (volume,
10
- * pairing PIN flow, capability discovery) lives on the [CastSession]
11
- * subtype that [connect] returns. Keeps the registry pluggable + the
9
+ * The interface is intentionally tiny. Anything richer lives on the
10
+ * concrete backend or on the [CastSession] subtype that [connect]
11
+ * returns volume on the session, the AirPlay 2 pairing-PIN flow on
12
+ * `AirPlayBackend.submitPairingPin`. Keeps the registry pluggable + the
12
13
  * routing layer protocol-agnostic.
13
14
  */
14
15
  interface CastBackend {
@@ -70,7 +70,7 @@ internal class CastEventBridge(private val owner: TrackPlayer) {
70
70
  sessionJob = scope.launch {
71
71
  launch {
72
72
  session.state.collectLatest { remoteState ->
73
- owner.emitRemoteState(mapState(remoteState), StateChangeReason.SYSTEM)
73
+ owner.emitRemoteState(mapRemotePlaybackState(remoteState), StateChangeReason.SYSTEM)
74
74
  }
75
75
  }
76
76
  launch {
@@ -110,13 +110,4 @@ internal class CastEventBridge(private val owner: TrackPlayer) {
110
110
  lastPositionMs = positionMs
111
111
  }
112
112
 
113
- private fun mapState(state: RemotePlaybackState): PlayerState = when (state) {
114
- RemotePlaybackState.PLAYING -> PlayerState.PLAYING
115
- RemotePlaybackState.PAUSED -> PlayerState.PAUSED
116
- RemotePlaybackState.LOADING -> PlayerState.BUFFERING
117
- RemotePlaybackState.BUFFERING -> PlayerState.BUFFERING
118
- RemotePlaybackState.IDLE -> PlayerState.NONE
119
- RemotePlaybackState.ENDED -> PlayerState.ENDED
120
- RemotePlaybackState.ERROR -> PlayerState.ERROR
121
- }
122
113
  }
@@ -61,9 +61,10 @@ sealed class CastSession {
61
61
 
62
62
  /**
63
63
  * Optional `Player` implementation for `MediaSession`. When
64
- * non-null, `MusicService` swaps `mediaSession.player` to this on
65
- * cast connect and back on disconnect. Chromecast returns a
66
- * `RemoteCastPlayer` from `media3-cast`. Default null.
64
+ * non-null, `PlaybackService` retargets the session's forwarding
65
+ * player to this on cast connect and back to the local engine on
66
+ * disconnect. Chromecast returns a `RemoteCastPlayer` from
67
+ * `media3-cast`. Default null.
67
68
  */
68
69
  open val mediaSessionPlayer: Player? = null
69
70
 
@@ -124,6 +125,19 @@ sealed class CastSession {
124
125
  /** Receiver-driven playback state for `PlaybackStateRouter`. */
125
126
  abstract val state: StateFlow<RemotePlaybackState>
126
127
 
128
+ /**
129
+ * `true` once the receiver has reported any state other than
130
+ * [RemotePlaybackState.IDLE]. Set by the first such report and stays
131
+ * set for the session's lifetime.
132
+ *
133
+ * State reads use this to decide whether the receiver can answer yet: a
134
+ * session that has just activated still holds its initial IDLE, and the
135
+ * local engine is the better answer until the receiver speaks. Once it
136
+ * has, the receiver is authoritative — including a later IDLE, which
137
+ * means it genuinely has nothing loaded.
138
+ */
139
+ abstract val hasReportedState: Boolean
140
+
127
141
  /** Receiver-driven position in ms. */
128
142
  abstract val positionMs: StateFlow<Long>
129
143
 
@@ -30,6 +30,25 @@ internal object CastTransportRouter {
30
30
  return true
31
31
  }
32
32
 
33
+ /**
34
+ * Flip the receiver's play state. The receiver's own state picks the
35
+ * direction — the local engine is parked and paused while casting, so its
36
+ * `playWhenReady` would resume the receiver on every press. Anything that
37
+ * represents "trying to play" toggles to pause, so a press during buffering
38
+ * stops rather than restarts.
39
+ */
40
+ fun routeTogglePlayPause(): Boolean {
41
+ val remote = PlaybackStateRouter.activeRemote() ?: return false
42
+ val active = when (remote.state.value) {
43
+ RemotePlaybackState.PLAYING,
44
+ RemotePlaybackState.BUFFERING,
45
+ RemotePlaybackState.LOADING -> true
46
+ else -> false
47
+ }
48
+ if (active) remote.pause() else remote.play()
49
+ return true
50
+ }
51
+
33
52
  fun routeStop(): Boolean {
34
53
  val remote = PlaybackStateRouter.activeRemote() ?: return false
35
54
  remote.stop()
@@ -0,0 +1,18 @@
1
+ package com.margelo.nitro.queueplayer.cast
2
+
3
+ import com.margelo.nitro.queueplayer.PlayerState
4
+
5
+ /**
6
+ * Map the protocol-neutral receiver state to the JS-facing [PlayerState].
7
+ * Shared by the cast event fan-out and `TrackPlayer.getState`, so a state read
8
+ * and the event stream can't disagree about the same receiver.
9
+ */
10
+ internal fun mapRemotePlaybackState(state: RemotePlaybackState): PlayerState = when (state) {
11
+ RemotePlaybackState.PLAYING -> PlayerState.PLAYING
12
+ RemotePlaybackState.PAUSED -> PlayerState.PAUSED
13
+ RemotePlaybackState.LOADING -> PlayerState.BUFFERING
14
+ RemotePlaybackState.BUFFERING -> PlayerState.BUFFERING
15
+ RemotePlaybackState.IDLE -> PlayerState.NONE
16
+ RemotePlaybackState.ENDED -> PlayerState.ENDED
17
+ RemotePlaybackState.ERROR -> PlayerState.ERROR
18
+ }
@@ -91,10 +91,8 @@ object AirPlay2JNI {
91
91
 
92
92
  // -- Pairing --
93
93
 
94
- @JvmStatic external fun nativeAp2NeedsPairing(handle: Long): Boolean
95
94
  @JvmStatic external fun nativeAp2PairPinStart(handle: Long): Boolean
96
95
  @JvmStatic external fun nativeAp2PairSetup(handle: Long, credential: String): String?
97
- @JvmStatic external fun nativeAp2GetPairingState(handle: Long): Int
98
96
  @JvmStatic external fun nativeAp2GetAuthMode(handle: Long): Int
99
97
  @JvmStatic external fun nativeAp2GetDeviceId(handle: Long): String?
100
98
  @JvmStatic external fun nativeAp2SetPassword(handle: Long, password: String)
@@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope
9
9
  import kotlinx.coroutines.Dispatchers
10
10
  import kotlinx.coroutines.SupervisorJob
11
11
  import kotlinx.coroutines.launch
12
+ import java.util.concurrent.atomic.AtomicReference
12
13
 
13
14
  /**
14
15
  * A live AirPlay 2 streaming session backed by libairplay2 via [AirPlay2JNI].
@@ -28,7 +29,7 @@ class AirPlay2Session(
28
29
  private val handle: Long,
29
30
  private val host: String,
30
31
  private val port: Int,
31
- ) : CastSession.PcmStream(), MetadataAwareSession {
32
+ ) : CastSession.PcmStream(), MetadataAwareSession, MetadataSyncTarget {
32
33
 
33
34
  companion object {
34
35
  private const val TAG = "AirPlay2Session"
@@ -36,7 +37,10 @@ class AirPlay2Session(
36
37
 
37
38
  private val sink = AirPlay2Sink(handle)
38
39
  @Volatile private var closed = false
39
- private var metadataSync: AirPlay2MetadataSync? = null
40
+ // Swapped on the player's looper by onActivated, read and cleared on the
41
+ // disconnecting thread — the exchange has to be atomic or a teardown can
42
+ // read a stale null and leave the previous sync running.
43
+ private val metadataSync = AtomicReference<AirPlayMetadataSync?>()
40
44
 
41
45
  /** Internal scope for fire-and-forget native teardown. */
42
46
  private val teardownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -53,22 +57,27 @@ class AirPlay2Session(
53
57
  }
54
58
 
55
59
  override fun onActivated(player: Player, scope: CoroutineScope) {
56
- // Re-entrant safe: stop any prior sync before rebinding so a repeat
60
+ if (closed) return
61
+ // Stop any prior sync before starting the replacement, so a repeat
57
62
  // activation (the player swaps under a still-connected session) never
58
63
  // leaves an orphaned sync running against the previous player.
59
- metadataSync?.stop()
60
- metadataSync = AirPlay2MetadataSync(context, this, player, scope).also { it.start() }
64
+ val next = AirPlayMetadataSync(context, this, player, scope)
65
+ metadataSync.getAndSet(next)?.stop()
66
+ next.start()
67
+ // A close() that ran while this was publishing exchanged out a null
68
+ // and tore down without seeing `next`, so re-check: otherwise a live
69
+ // sync stays bound to a closed session for the process lifetime.
70
+ if (closed) metadataSync.getAndSet(null)?.stop()
61
71
  }
62
72
 
63
73
  override fun onDeactivated() {
64
- metadataSync?.stop()
65
- metadataSync = null
74
+ metadataSync.getAndSet(null)?.stop()
66
75
  }
67
76
 
68
77
  /**
69
78
  * Invoked from the native drain thread (via [AirPlay2JNI.onNativeSessionDied])
70
79
  * when the receiver has stopped responding. Notifies any [deathListener]
71
- * so CastModule can revert routing to local, then runs the normal close
80
+ * so `CastManager` can revert routing to local, then runs the normal close
72
81
  * path — native TEARDOWN is skipped because the socket is dead.
73
82
  */
74
83
  private fun onRemoteDied(reason: Int) {
@@ -91,7 +100,9 @@ class AirPlay2Session(
91
100
  AirPlay2JNI.unregisterSession(handle)
92
101
 
93
102
  /* Native TEARDOWN can block on a dead socket — never run it on the
94
- * caller's thread (typically the main/UI thread via CastModule). */
103
+ * caller's thread (`CastManager.disconnect()` on the JS thread, a
104
+ * replacing `connect()` or `submitPairingPin()` on `Dispatchers.IO`,
105
+ * `onRemoteDied` on the native drain thread). */
95
106
  val h = handle
96
107
  teardownScope.launch {
97
108
  try {
@@ -103,17 +114,17 @@ class AirPlay2Session(
103
114
  }
104
115
  }
105
116
 
106
- fun setMetadata(title: String?, artist: String?, album: String?) {
117
+ override fun setMetadata(title: String?, artist: String?, album: String?) {
107
118
  if (closed) return
108
119
  AirPlay2JNI.nativeAp2SetMetadata(handle, title, artist, album)
109
120
  }
110
121
 
111
- fun setArtwork(imageData: ByteArray, contentType: String = "image/jpeg") {
122
+ override fun setArtwork(imageData: ByteArray, contentType: String) {
112
123
  if (closed) return
113
124
  AirPlay2JNI.nativeAp2SetArtwork(handle, imageData, imageData.size)
114
125
  }
115
126
 
116
- fun setProgress(elapsedMs: Int, durationMs: Int) {
127
+ override fun setProgress(elapsedMs: Int, durationMs: Int) {
117
128
  if (closed) return
118
129
  AirPlay2JNI.nativeAp2SetProgressMs(handle, elapsedMs.toLong(), durationMs.toLong())
119
130
  }
@@ -97,22 +97,28 @@ internal class AirPlayBackend(private val context: Context) : CastBackend {
97
97
 
98
98
  /**
99
99
  * Complete a deferred AP2 pairing by supplying the PIN / password the
100
- * receiver demanded. Returns the active session on success; throws on
101
- * pairing failure or when no pending pair exists.
100
+ * receiver demanded. Returns the session and the receiver it was
101
+ * paired with; throws on pairing failure or when no pending pair
102
+ * exists.
102
103
  *
103
104
  * Callers use this when `CastManager.submitPairingPin(pin)` is invoked
104
105
  * out-of-band of a follow-up `connect(receiverId, {password})` call.
105
106
  */
106
- suspend fun submitPairingPin(pin: String): CastSession = withContext(Dispatchers.IO) {
107
+ suspend fun submitPairingPin(pin: String): PairedSession = withContext(Dispatchers.IO) {
107
108
  val pending = synchronized(this@AirPlayBackend) { pendingPairState }
108
109
  ?: throw IllegalStateException("No pending AirPlay 2 pairing")
109
110
  val receiver = discovery.findReceiver(pending.receiverId)
110
111
  ?: throw IllegalStateException(
111
112
  "Receiver ${pending.receiverId} no longer visible — restart the connect flow"
112
113
  )
114
+ // resumeAp2Pairing reports the id of the pair it consumed and
115
+ // validated against `receiver`; the read above only locates it.
113
116
  resumeAp2Pairing(receiver, pin)
114
117
  }
115
118
 
119
+ /** A session established by [submitPairingPin] and the receiver it was paired with. */
120
+ data class PairedSession(val session: CastSession, val receiverId: String)
121
+
116
122
  /**
117
123
  * Persistent store for AirPlay 2 pair-setup auth keys + passwords,
118
124
  * keyed by the receiver's mDNS `deviceid` (MAC address).
@@ -306,7 +312,7 @@ internal class AirPlayBackend(private val context: Context) : CastBackend {
306
312
 
307
313
  // --- Phase 2: credential supplied → resume pending pairing ---
308
314
  if (credential != null) {
309
- return resumeAp2Pairing(receiver, credential)
315
+ return resumeAp2Pairing(receiver, credential).session
310
316
  }
311
317
 
312
318
  // --- Phase 1: initial connection attempt ---
@@ -423,11 +429,12 @@ internal class AirPlayBackend(private val context: Context) : CastBackend {
423
429
  }
424
430
 
425
431
  /**
426
- * Resume a pending AP2 pairing with user-provided credential.
427
- * Called when the UI re-invokes connect() with a password/PIN after
428
- * [AuthRequiredException] was thrown.
432
+ * Resume a pending AP2 pairing with user-provided credential, from
433
+ * either `connect()` re-invoked with a password or [submitPairingPin].
434
+ * Consumes the pending pair, validates it is for [receiver], and
435
+ * returns the session together with that receiver's id.
429
436
  */
430
- private fun resumeAp2Pairing(receiver: Receiver, credential: String): CastSession {
437
+ private fun resumeAp2Pairing(receiver: Receiver, credential: String): PairedSession {
431
438
  val pending = synchronized(this) {
432
439
  val p = pendingPairState
433
440
  pendingPairState = null
@@ -506,7 +513,7 @@ internal class AirPlayBackend(private val context: Context) : CastBackend {
506
513
  }
507
514
 
508
515
  Log.i(TAG, "Connected AP2 to ${receiver.name} via pair-verify")
509
- return AirPlay2Session(context, newHandle, host, port)
516
+ return PairedSession(AirPlay2Session(context, newHandle, host, port), pending.receiverId)
510
517
  }
511
518
 
512
519
  /** Destroy any stale pending pairing handle. */
@@ -14,19 +14,23 @@ import java.io.ByteArrayOutputStream
14
14
  import java.net.URL
15
15
 
16
16
  /**
17
- * Syncs ExoPlayer track metadata and playback progress to an [AirPlaySession].
17
+ * Syncs ExoPlayer track metadata and playback progress to an AirPlay
18
+ * receiver through [MetadataSyncTarget].
18
19
  *
19
- * - On activation and on every track transition: sends DAAP metadata
20
+ * - On activation and on every track transition: sends metadata
20
21
  * (title/artist/album) and kicks off an async artwork fetch.
21
22
  * - Every 5 seconds while playing: sends progress (elapsed/duration).
22
23
  * - On seek: sends progress immediately.
23
24
  * - On stop: cancels all coroutines and removes the player listener.
25
+ *
26
+ * One instance covers one activation — [start] then [stop]. Starting again
27
+ * after a stop is a no-op; a new activation builds a new instance.
24
28
  */
25
29
  internal class AirPlayMetadataSync(
26
30
  private val context: Context,
27
- private val session: AirPlaySession,
31
+ private val target: MetadataSyncTarget,
28
32
  private val player: Player,
29
- private val scope: CoroutineScope,
33
+ scope: CoroutineScope,
30
34
  ) {
31
35
  companion object {
32
36
  private const val TAG = "AirPlayMetadataSync"
@@ -35,11 +39,32 @@ internal class AirPlayMetadataSync(
35
39
  private const val JPEG_QUALITY = 80
36
40
  }
37
41
 
42
+ /**
43
+ * Owns every coroutine this sync launches, so [stop] can cancel them as
44
+ * one atomic operation from any thread. Cancelling the scope needs no
45
+ * field read, so a teardown racing the player looper cannot miss a job,
46
+ * and a launch that loses the race starts already-cancelled rather than
47
+ * outliving the session it pushes into.
48
+ */
49
+ private val syncScope =
50
+ CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job]))
51
+
52
+ // Touched only on the player's application looper.
38
53
  private var progressJob: Job? = null
39
54
  private var artworkJob: Job? = null
40
55
  private var lastArtworkUri: String? = null
56
+
57
+ // Written by start/stop on whichever thread activated or tore the
58
+ // session down; read on the looper and by the progress loop.
59
+ @Volatile
41
60
  private var started = false
42
61
 
62
+ // Set on stop and never cleared, so a restart cannot half-revive a spent instance: the
63
+ // scope is already cancelled, so the listener would still push metadata
64
+ // while the progress loop and artwork fetch stay dead.
65
+ @Volatile
66
+ private var stopped = false
67
+
43
68
  // ExoPlayer rejects add/removeListener + state reads off its application
44
69
  // looper. start()/stop() are driven by session activation/close, and
45
70
  // close() runs on whichever thread disconnected (the JS thread for a
@@ -48,6 +73,10 @@ internal class AirPlayMetadataSync(
48
73
  // already on it.
49
74
  private val playerHandler = Handler(player.applicationLooper)
50
75
 
76
+ // Both protocols log under one tag, so every line says which sender it
77
+ // came from.
78
+ private val targetName = target.javaClass.simpleName
79
+
51
80
  private fun onPlayerLooper(block: () -> Unit) {
52
81
  if (Looper.myLooper() == player.applicationLooper) block()
53
82
  else playerHandler.post(block)
@@ -69,6 +98,7 @@ internal class AirPlayMetadataSync(
69
98
  }
70
99
 
71
100
  override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
101
+ if (!started) return
72
102
  if (playWhenReady) {
73
103
  startProgressTimer()
74
104
  } else {
@@ -79,37 +109,43 @@ internal class AirPlayMetadataSync(
79
109
  }
80
110
 
81
111
  fun start() {
82
- if (started) return
112
+ if (started || stopped) return
83
113
  started = true
84
114
  onPlayerLooper {
115
+ // A stop() between this post and its execution has already run its
116
+ // own removeListener, so attaching now would leave the listener on
117
+ // the player for its lifetime.
118
+ if (stopped) return@onPlayerLooper
85
119
  player.addListener(listener)
86
120
  sendCurrentMetadata()
87
- if (player.playWhenReady) {
121
+ if (started && player.playWhenReady) {
88
122
  startProgressTimer()
89
123
  }
90
124
  }
91
- Log.i(TAG, "Started metadata sync")
125
+ Log.i(TAG, "Started metadata sync for $targetName")
92
126
  }
93
127
 
94
128
  fun stop() {
95
- if (!started) return
129
+ if (stopped) return
130
+ stopped = true
96
131
  started = false
97
- progressJob?.cancel()
98
- progressJob = null
99
- artworkJob?.cancel()
100
- artworkJob = null
132
+ syncScope.cancel()
133
+ // Listener removal has to hop to the looper, so a callback queued
134
+ // ahead of it can still run; the `started` gate in the senders is
135
+ // what stops those from reaching a session that is being closed.
101
136
  onPlayerLooper { player.removeListener(listener) }
102
- Log.i(TAG, "Stopped metadata sync")
137
+ Log.i(TAG, "Stopped metadata sync for $targetName")
103
138
  }
104
139
 
105
140
  private fun sendCurrentMetadata() {
141
+ if (!started) return
106
142
  val meta = player.currentMediaItem?.mediaMetadata ?: return
107
143
  val title = meta.title?.toString()
108
144
  val artist = meta.artist?.toString()
109
145
  val album = meta.albumTitle?.toString()
110
146
 
111
- Log.i(TAG, "Sending metadata: title=$title, artist=$artist, album=$album")
112
- session.setMetadata(title, artist, album)
147
+ Log.i(TAG, "$targetName sending metadata: title=$title, artist=$artist, album=$album")
148
+ target.setMetadata(title, artist, album)
113
149
 
114
150
  // Send initial progress for the new track
115
151
  sendProgress()
@@ -123,16 +159,17 @@ internal class AirPlayMetadataSync(
123
159
  }
124
160
 
125
161
  private fun sendProgress() {
162
+ if (!started) return
126
163
  val position = player.currentPosition.toInt()
127
164
  val duration = player.duration.let { if (it > 0) it.toInt() else 0 }
128
165
  if (duration > 0) {
129
- session.setProgress(position, duration)
166
+ target.setProgress(position, duration)
130
167
  }
131
168
  }
132
169
 
133
170
  private fun startProgressTimer() {
134
171
  progressJob?.cancel()
135
- progressJob = scope.launch {
172
+ progressJob = syncScope.launch {
136
173
  while (isActive) {
137
174
  delay(PROGRESS_INTERVAL_MS)
138
175
  if (player.isPlaying) {
@@ -144,7 +181,7 @@ internal class AirPlayMetadataSync(
144
181
 
145
182
  private fun fetchAndSendArtwork(uriString: String) {
146
183
  artworkJob?.cancel()
147
- artworkJob = scope.launch(Dispatchers.IO) {
184
+ artworkJob = syncScope.launch(Dispatchers.IO) {
148
185
  try {
149
186
  val uri = Uri.parse(uriString)
150
187
  val scheme = uri.scheme ?: return@launch
@@ -185,9 +222,9 @@ internal class AirPlayMetadataSync(
185
222
  bitmap.recycle()
186
223
 
187
224
  val bytes = baos.toByteArray()
188
- if (isActive) {
189
- session.setArtwork(bytes, "image/jpeg")
190
- Log.i(TAG, "Sent artwork: ${bytes.size} bytes from $uriString")
225
+ if (isActive && started) {
226
+ target.setArtwork(bytes, "image/jpeg")
227
+ Log.i(TAG, "$targetName sent artwork: ${bytes.size} bytes from $uriString")
191
228
  }
192
229
  } catch (e: Exception) {
193
230
  if (e !is CancellationException) {
@@ -10,12 +10,12 @@ import androidx.media3.exoplayer.audio.AudioSink
10
10
  * [AudioSink] IS the AirPlay session's sink ([AirPlay2Sink] / [AirPlaySink]).
11
11
  *
12
12
  * Deliberately a bare [DefaultRenderersFactory]: no `setEnableAudioFloatOutput`,
13
- * no audio processors. The AirPlay sinks only accept `AUDIO_RAW` +
14
- * `ENCODING_PCM_16BIT`, and the default factory decodes to Int16 PCM — so the
15
- * renderer negotiates a format the sink accepts and the PCM reaches the
16
- * receiver clean. (Forcing float output or inserting a processor chain is what
17
- * corrupted the AirPlay stream on the shared local engine; this engine is kept
18
- * dedicated and clean precisely to avoid that.)
13
+ * no audio processors, no extension-renderer preference. The AirPlay sinks
14
+ * only accept `AUDIO_RAW` + `ENCODING_PCM_16BIT`, and the default factory
15
+ * decodes to Int16 PCM — so the renderer negotiates a format the sink accepts
16
+ * and the PCM reaches the receiver clean. Float output or a processor chain
17
+ * in front of the sink corrupts the AirPlay stream, which is why this engine
18
+ * has its own factory instead of sharing the local pipeline's.
19
19
  *
20
20
  * One sink per factory instance — the AirPlay engine is built fresh for each
21
21
  * session and released when the session ends, so the sink is never swapped at
@@ -27,14 +27,6 @@ internal class AirPlayRenderersFactory(
27
27
  private val airplaySink: AudioSink,
28
28
  ) : DefaultRenderersFactory(context) {
29
29
 
30
- init {
31
- // Match the known-good fork AirPlay config: prefer extension decoders.
32
- // No-op when no extension renderers are bundled, but it keeps the
33
- // decoder-selection behaviour identical to the reference that streams
34
- // clean PCM to these sinks.
35
- setExtensionRendererMode(EXTENSION_RENDERER_MODE_PREFER)
36
- }
37
-
38
30
  override fun buildAudioSink(
39
31
  context: Context,
40
32
  enableFloatOutput: Boolean,
@@ -6,6 +6,7 @@ import androidx.media3.exoplayer.audio.AudioSink
6
6
  import com.margelo.nitro.queueplayer.cast.CastSession
7
7
  import com.margelo.nitro.queueplayer.cast.MetadataAwareSession
8
8
  import kotlinx.coroutines.CoroutineScope
9
+ import java.util.concurrent.atomic.AtomicReference
9
10
 
10
11
  /**
11
12
  * A live AirPlay 1 streaming session backed by libraop via [AirplayJNI].
@@ -25,15 +26,22 @@ class AirPlaySession(
25
26
  private val handle: Long,
26
27
  private val host: String,
27
28
  private val port: Int,
28
- ) : CastSession.PcmStream(), MetadataAwareSession {
29
+ ) : CastSession.PcmStream(), MetadataAwareSession, MetadataSyncTarget {
29
30
 
30
31
  companion object {
31
32
  private const val TAG = "AirPlaySession"
32
33
  }
33
34
 
34
35
  private val sink = AirPlaySink(handle)
36
+
37
+ // Read by every sender, set by close() on whichever thread disconnected.
38
+ @Volatile
35
39
  private var closed = false
36
- private var metadataSync: AirPlayMetadataSync? = null
40
+
41
+ // Swapped on the player's looper by onActivated, read and cleared on the
42
+ // disconnecting thread — the exchange has to be atomic or a teardown can
43
+ // read a stale null and leave the previous sync running.
44
+ private val metadataSync = AtomicReference<AirPlayMetadataSync?>()
37
45
 
38
46
  override fun audioSink(): AudioSink = sink
39
47
 
@@ -46,22 +54,29 @@ class AirPlaySession(
46
54
  }
47
55
 
48
56
  override fun onActivated(player: Player, scope: CoroutineScope) {
49
- // Re-entrant safe: stop any prior sync before rebinding so a repeat
57
+ if (closed) return
58
+ // Stop any prior sync before starting the replacement, so a repeat
50
59
  // activation (the player swaps under a still-connected session) never
51
60
  // leaves an orphaned sync running against the previous player.
52
- metadataSync?.stop()
53
- metadataSync = AirPlayMetadataSync(context, this, player, scope).also { it.start() }
61
+ val next = AirPlayMetadataSync(context, this, player, scope)
62
+ metadataSync.getAndSet(next)?.stop()
63
+ next.start()
64
+ // A close() that ran while this was publishing exchanged out a null
65
+ // and tore down without seeing `next`, so re-check: otherwise a live
66
+ // sync stays bound to a closed session for the process lifetime.
67
+ if (closed) metadataSync.getAndSet(null)?.stop()
54
68
  }
55
69
 
56
70
  override fun onDeactivated() {
57
- metadataSync?.stop()
58
- metadataSync = null
71
+ metadataSync.getAndSet(null)?.stop()
59
72
  }
60
73
 
61
74
  override fun close() {
62
75
  if (closed) return
63
- onDeactivated()
76
+ // Closed before the sync is torn down so an in-flight sender bails at
77
+ // its own guard rather than reaching a handle nativeDestroy has freed.
64
78
  closed = true
79
+ onDeactivated()
65
80
  Log.i(TAG, "Closing AirPlay session to $host:$port")
66
81
  // Release the sink BEFORE the native handle is torn down: this sets
67
82
  // the sink's volatile `released` flag so the renderer thread stops
@@ -73,20 +88,17 @@ class AirPlaySession(
73
88
  AirplayJNI.nativeDestroy(handle)
74
89
  }
75
90
 
76
- /** Send DAAP metadata (title/artist/album) to the receiver. */
77
- fun setMetadata(title: String?, artist: String?, album: String?) {
91
+ override fun setMetadata(title: String?, artist: String?, album: String?) {
78
92
  if (closed) return
79
93
  AirplayJNI.nativeSetDaap(handle, title, artist, album)
80
94
  }
81
95
 
82
- /** Send artwork image to the receiver. */
83
- fun setArtwork(imageData: ByteArray, contentType: String = "image/jpeg") {
96
+ override fun setArtwork(imageData: ByteArray, contentType: String) {
84
97
  if (closed) return
85
98
  AirplayJNI.nativeSetArtwork(handle, imageData, imageData.size, contentType)
86
99
  }
87
100
 
88
- /** Update the progress bar on the receiver. */
89
- fun setProgress(elapsedMs: Int, durationMs: Int) {
101
+ override fun setProgress(elapsedMs: Int, durationMs: Int) {
90
102
  if (closed) return
91
103
  AirplayJNI.nativeSetProgressMs(handle, elapsedMs, durationMs)
92
104
  }
@@ -40,11 +40,6 @@ object AirplayJNI {
40
40
  false
41
41
  }
42
42
 
43
- // -- Library info --
44
-
45
- @JvmStatic external fun nativeVersion(): String
46
- @JvmStatic external fun nativeInitSsl(): Boolean
47
-
48
43
  // -- Session lifecycle --
49
44
 
50
45
  /**
@@ -73,10 +68,8 @@ object AirplayJNI {
73
68
  /** Bytes waiting in the ring buffer, not yet sent. Used for hasPendingData(). */
74
69
  @JvmStatic external fun nativeRingAvailable(handle: Long): Int
75
70
 
76
- // -- Audio streaming (legacy, kept for compatibility) --
71
+ // -- Transport control --
77
72
 
78
- @JvmStatic external fun nativeAcceptFrames(handle: Long): Boolean
79
- @JvmStatic external fun nativeSendChunk(handle: Long, data: ByteArray, offset: Int, length: Int): Boolean
80
73
  @JvmStatic external fun nativePause(handle: Long)
81
74
  @JvmStatic external fun nativeStop(handle: Long)
82
75
  @JvmStatic external fun nativeFlush(handle: Long): Boolean
@@ -91,16 +84,11 @@ object AirplayJNI {
91
84
 
92
85
  @JvmStatic external fun nativeSetVolume(handle: Long, volume: Float): Boolean
93
86
  @JvmStatic external fun nativeSetProgressMs(handle: Long, elapsed: Int, duration: Int): Boolean
94
- @JvmStatic external fun nativeGetProgressMs(handle: Long): Long
95
87
  @JvmStatic external fun nativeSetDaap(handle: Long, title: String?, artist: String?, album: String?): Boolean
96
88
  @JvmStatic external fun nativeSetArtwork(handle: Long, imageData: ByteArray, length: Int, contentType: String): Boolean
97
89
 
98
90
  // -- State queries --
99
91
 
100
92
  @JvmStatic external fun nativeIsConnected(handle: Long): Boolean
101
- @JvmStatic external fun nativeIsPlaying(handle: Long): Boolean
102
- @JvmStatic external fun nativeGetState(handle: Long): Int
103
- @JvmStatic external fun nativeGetLatency(handle: Long): Int
104
- @JvmStatic external fun nativeGetSampleRate(handle: Long): Int
105
93
  @JvmStatic external fun nativeKeepalive(handle: Long): Boolean
106
94
  }