react-native-queue-player 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QueuePlayer.podspec +14 -9
- package/README.md +3 -3
- package/android/consumer-rules.pro +1 -1
- package/android/src/main/cpp/airplay2_control.c +4 -4
- package/android/src/main/cpp/airplay2_control.h +4 -4
- package/android/src/main/cpp/airplay2_jni.cpp +3 -19
- package/android/src/main/cpp/airplay2_pair.c +7 -7
- package/android/src/main/cpp/airplay2_pair.h +2 -2
- package/android/src/main/cpp/airplay2_rtsp.c +8 -8
- package/android/src/main/cpp/airplay2_session.c +2 -2
- package/android/src/main/cpp/airplay_jni.cpp +8 -69
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
- package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
- package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
- package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
- package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +839 -271
- package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
- package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
- package/app.plugin.js +17 -1
- package/ios/AVPlayerItemQueueItemId.swift +26 -12
- package/ios/AVQueueBuilder.swift +120 -85
- package/ios/ArtworkLoader.swift +0 -7
- package/ios/ArtworkResolver.swift +22 -4
- package/ios/AssetReplayGainReader.swift +118 -0
- package/ios/AudioSession.swift +67 -16
- package/ios/AudioTapProvider.swift +284 -149
- package/ios/CarPlayBridge.swift +0 -12
- package/ios/CarPlayCoordinator.swift +18 -2
- package/ios/CarPlaySceneDelegate.swift +2 -2
- package/ios/Cast/AirPlayRouteState.swift +9 -8
- package/ios/Cast/Core/CastEventBridge.swift +66 -26
- package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
- package/ios/Cast/Core/CastSession.swift +2 -2
- package/ios/Cast/Core/CastTransportRouter.swift +21 -9
- package/ios/Cast/Core/LocalMediaServer.swift +41 -52
- package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
- package/ios/Cast/Core/MediaServerHandle.swift +0 -4
- package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
- package/ios/CastManager.swift +1 -1
- package/ios/CrossfadeEngine.swift +660 -226
- package/ios/EQTap.swift +20 -17
- package/ios/Equalizer.swift +1 -1
- package/ios/FLACStreamInfo.swift +65 -0
- package/ios/GaplessEngine.swift +57 -30
- package/ios/InputGuards.swift +35 -5
- package/ios/LookaheadCache.swift +202 -58
- package/ios/LookaheadCachePrefetcher.swift +48 -36
- package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
- package/ios/NetworkRecoveryPolicy.swift +32 -0
- package/ios/NowPlayingFormatExtractor.swift +25 -18
- package/ios/NowPlayingInfo.swift +84 -48
- package/ios/OutputRouteMonitor.swift +16 -3
- package/ios/PendingBrowseRequests.swift +1 -1
- package/ios/PlaceholderArtwork.swift +9 -5
- package/ios/PlaybackEngine.swift +165 -76
- package/ios/PlaybackErrorMapping.swift +3 -3
- package/ios/PlaybackModeStateMachine.swift +2 -2
- package/ios/PlaybackNetworkMonitor.swift +73 -0
- package/ios/PlayerStateDerivation.swift +6 -6
- package/ios/QueueMutationArithmetic.swift +25 -0
- package/ios/QueueSkipArithmetic.swift +5 -3
- package/ios/QueueWindowArithmetic.swift +152 -0
- package/ios/ReadThroughServer.swift +283 -0
- package/ios/RemoteCommands.swift +44 -4
- package/ios/ReplayGainData.swift +22 -3
- package/ios/ReplayGainExtractor.swift +70 -27
- package/ios/Siri/VoiceDonation.swift +14 -9
- package/ios/StreamingBitrateProbe.swift +22 -68
- package/ios/SuppliedReplayGain.swift +83 -0
- package/ios/Tests/AVQueueBuilderTests.swift +158 -221
- package/ios/Tests/ActiveItemEngineStub.swift +44 -0
- package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
- package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
- package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
- package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
- package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
- package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
- package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
- package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
- package/ios/Tests/CrossfadeEngineTests.swift +164 -0
- package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
- package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
- package/ios/Tests/EngineSwapTests.swift +91 -0
- package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
- package/ios/Tests/EqualizerHybridTests.swift +5 -2
- package/ios/Tests/FLACStreamInfoTests.swift +88 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
- package/ios/Tests/InputGuardsTests.swift +41 -0
- package/ios/Tests/InterruptionIntentTests.swift +77 -0
- package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
- package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
- package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
- package/ios/Tests/LookaheadCacheTests.swift +44 -18
- package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
- package/ios/Tests/MutationDeferralTests.swift +220 -0
- package/ios/Tests/NowPlayingInfoTests.swift +86 -99
- package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
- package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
- package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
- package/ios/Tests/PlayerFixtures.swift +48 -0
- package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
- package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
- package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
- package/ios/Tests/QueueStateTests.swift +3 -1
- package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
- package/ios/Tests/QueueWindowSliceTests.swift +162 -0
- package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
- package/ios/Tests/ReadThroughServerTests.swift +345 -0
- package/ios/Tests/RemoteCommandsTests.swift +28 -0
- package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
- package/ios/Tests/ReplayGainMergeTests.swift +230 -0
- package/ios/Tests/RetryRecoveryTests.swift +303 -0
- package/ios/Tests/SkipCapabilityTests.swift +233 -8
- package/ios/Tests/SkipIndexTests.swift +32 -2
- package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
- package/ios/Tests/StallRecoveryTests.swift +97 -0
- package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
- package/ios/Tests/TopUpWindowGateTests.swift +394 -0
- package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
- package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
- package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
- package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
- package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
- package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
- package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
- package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
- package/ios/TrackPlayer+Automotive.swift +101 -0
- package/ios/TrackPlayer+Cache.swift +232 -0
- package/ios/TrackPlayer+Config.swift +291 -0
- package/ios/TrackPlayer+EngineDelegate.swift +209 -0
- package/ios/TrackPlayer+EventsAPI.swift +111 -0
- package/ios/TrackPlayer+EventsDispatch.swift +968 -0
- package/ios/TrackPlayer+EventsWiring.swift +173 -0
- package/ios/TrackPlayer+Lifecycle.swift +930 -0
- package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
- package/ios/TrackPlayer+Queue.swift +940 -0
- package/ios/TrackPlayer+Recovery.swift +196 -0
- package/ios/TrackPlayer+Skip.swift +455 -0
- package/ios/TrackPlayer+SleepTimer.swift +175 -0
- package/ios/TrackPlayer+State.swift +108 -0
- package/ios/TrackPlayer+Threading.swift +135 -0
- package/ios/TrackPlayer+Transport.swift +267 -0
- package/ios/TrackPlayer+Window.swift +195 -0
- package/ios/TrackPlayer.swift +305 -4619
- package/ios/Visualizer.swift +6 -5
- package/ios/tests-harness/Podfile +1 -1
- package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
- package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
- package/lib/module/hooks/useActiveTrack.js +29 -22
- package/lib/module/hooks/useActiveTrack.js.map +1 -1
- package/lib/module/hooks/useCast.js +8 -26
- package/lib/module/hooks/useCast.js.map +1 -1
- package/lib/module/hooks/useEqualizer.js +19 -12
- package/lib/module/hooks/useEqualizer.js.map +1 -1
- package/lib/module/hooks/useLookaheadCache.js +3 -7
- package/lib/module/hooks/useLookaheadCache.js.map +1 -1
- package/lib/module/hooks/useQueue.js +66 -19
- package/lib/module/hooks/useQueue.js.map +1 -1
- package/lib/module/index.js +6 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/queueDelta.js +41 -0
- package/lib/module/queueDelta.js.map +1 -0
- package/lib/module/types.js +28 -5
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
- package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
- package/lib/typescript/hooks/useCast.d.ts +6 -1
- package/lib/typescript/hooks/useCast.d.ts.map +1 -1
- package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
- package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
- package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +4 -4
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/queueDelta.d.ts +10 -0
- package/lib/typescript/queueDelta.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +168 -12
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
- package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
- package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
- package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
- 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
- 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
- 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
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
- 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
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
- package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
- package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
- package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
- package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
- package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
- package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
- package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
- package/package.json +8 -8
- package/src/TrackPlayer.nitro.ts +53 -27
- package/src/hooks/useActiveTrack.ts +29 -22
- package/src/hooks/useCast.ts +14 -15
- package/src/hooks/useEqualizer.ts +19 -12
- package/src/hooks/useLookaheadCache.ts +3 -7
- package/src/hooks/useQueue.ts +66 -17
- package/src/index.ts +13 -3
- package/src/queueDelta.ts +41 -0
- package/src/types.ts +169 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
- package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
- package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
- package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
- package/ios/MetadataReader.swift +0 -555
- package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
- package/ios/Tests/MetadataReaderTests.swift +0 -603
- package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
- package/ios/Tests/VoiceDonationTests.swift +0 -26
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
- /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension TrackPlayer {
|
|
5
|
+
// MARK: - Queue mutation
|
|
6
|
+
//
|
|
7
|
+
// All mutations operate on two parallel structures:
|
|
8
|
+
// 1. `self.tracks` — the authoritative ordered TrackItem list.
|
|
9
|
+
// 2. The engine's installed run — the `QueueWindowArithmetic.slice`
|
|
10
|
+
// around `currentTrackIndex`, reconciled by `fullRebuildPlayerQueue`
|
|
11
|
+
// and the surgical insert/remove calls without tearing down the
|
|
12
|
+
// currently-playing item when possible.
|
|
13
|
+
//
|
|
14
|
+
// Thread safety: every mutation body is enqueued onto `playerQueue` by
|
|
15
|
+
// `enqueueThenMirror`.
|
|
16
|
+
// AVFoundation carries no main-thread requirement at the 16.0 floor, but it
|
|
17
|
+
// is not thread-safe either, so one queue owns every player mutation. The
|
|
18
|
+
// hop also serialises concurrent JS calls — two back-to-back `addToQueue` +
|
|
19
|
+
// `removeFromQueue` invocations cannot interleave, because the queue is
|
|
20
|
+
// serial.
|
|
21
|
+
|
|
22
|
+
/// Belt-and-braces: verify the parallel-array invariant before
|
|
23
|
+
/// any function that consumes both arrays. `QueueState` enforces
|
|
24
|
+
/// this at every mutation site, but a direct read of either array
|
|
25
|
+
/// outside `queueState`'s API can drift if a future contributor
|
|
26
|
+
/// bypasses the type. Catches the desync earlier than the
|
|
27
|
+
/// construction-time `precondition` in `AVQueueBuilder`.
|
|
28
|
+
/// Debug-only — the precondition vanishes in Release builds where
|
|
29
|
+
/// `AVQueueBuilder` already fails soft (returns `[]` + NSLog) on
|
|
30
|
+
/// the same desync.
|
|
31
|
+
private func assertQueueIdsInvariant(_ caller: StaticString) {
|
|
32
|
+
#if DEBUG
|
|
33
|
+
precondition(
|
|
34
|
+
self.tracks.count == self.queueItemIds.count,
|
|
35
|
+
"QueueState parallel-array invariant violated at \(caller): tracks=\(self.tracks.count), queueItemIds=\(self.queueItemIds.count)"
|
|
36
|
+
)
|
|
37
|
+
#endif
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Raise `isMutatingPlayerQueue` for the duration of `body`,
|
|
41
|
+
/// restoring the prior value on exit (so nested calls stay true
|
|
42
|
+
/// throughout the outer mutation). Used to suppress spurious
|
|
43
|
+
/// `onQueueEnd` / `onTrackChange` fires from the `\.currentItem`
|
|
44
|
+
/// KVO during the `removeAllItems` → `insert` intermediate nil
|
|
45
|
+
/// window. Must be called on `playerQueue`.
|
|
46
|
+
internal func performingMutation(_ body: () -> Void) {
|
|
47
|
+
let wasAlready = self.isMutatingPlayerQueue
|
|
48
|
+
self.isMutatingPlayerQueue = true
|
|
49
|
+
defer { self.isMutatingPlayerQueue = wasAlready }
|
|
50
|
+
body()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func setQueue(tracks: [TrackItem], startAtIndex: Double?) throws -> Promise<Void> {
|
|
54
|
+
// The local queue model is replaced **at call time**, before any cast
|
|
55
|
+
// round-trip, so the mutation reaches the player in the order JS issued it
|
|
56
|
+
// — a `skipToNext()` called straight after this one cannot overtake it.
|
|
57
|
+
// The receiver is mirrored afterwards from the Task below.
|
|
58
|
+
//
|
|
59
|
+
// Local state is maintained whether or not the receiver takes the queue:
|
|
60
|
+
// JS reads (`getQueue` / `getCurrentTrackIndex`) reflect the user-visible
|
|
61
|
+
// queue while the receiver is authoritative for playback, and the local
|
|
62
|
+
// engine is left holding the right items for when cast disconnects. The
|
|
63
|
+
// engine is already paused during cast and the rebuild below keeps it
|
|
64
|
+
// paused, so this is silent either way.
|
|
65
|
+
return enqueueThenMirror({
|
|
66
|
+
self.performingMutation {
|
|
67
|
+
// `self.engine` is the canonical "lib initialised" sentinel.
|
|
68
|
+
// `self.player` is the gapless engine's AVQueuePlayer and is
|
|
69
|
+
// nil under CrossfadeEngine — gating on it here would silently
|
|
70
|
+
// no-op every queue mutation made while in crossfade mode.
|
|
71
|
+
guard self.engine != nil else { return }
|
|
72
|
+
let newIds = self.queueState.replaceAll(tracks)
|
|
73
|
+
self.currentTrackIndex = Self.resolvedStartIndex(
|
|
74
|
+
startAtIndex: startAtIndex, trackCount: tracks.count)
|
|
75
|
+
self.pendingTrackChangeReason = .queueReplaced
|
|
76
|
+
// A fresh queue is not over, whatever the one it replaces was.
|
|
77
|
+
self.reachedQueueEnd = false
|
|
78
|
+
// Fresh queue → fresh dedup state. Without this an error on
|
|
79
|
+
// the prior queue's last item could suppress an identical
|
|
80
|
+
// coded error on the new queue's first item.
|
|
81
|
+
self.lastErrorQueueItemId = nil
|
|
82
|
+
self.lastErrorCode = nil
|
|
83
|
+
// Reset auto-retry counters: new queueItemIds → new budget
|
|
84
|
+
// entries. The retry budget is per-queue-position; old
|
|
85
|
+
// entries from the prior queue are gone with the prior
|
|
86
|
+
// queueItemIds, and a missing entry reads as zero retries.
|
|
87
|
+
let attempts = self.effectiveAutoRetries()
|
|
88
|
+
self.retryAttemptsRemaining = Dictionary(
|
|
89
|
+
uniqueKeysWithValues: newIds.map { ($0, attempts) }
|
|
90
|
+
)
|
|
91
|
+
// A fresh queue diverges at position 0, so the rebuild installs the
|
|
92
|
+
// whole slice and the resolved start track gets initial-buffer
|
|
93
|
+
// leniency from the rearm on that path.
|
|
94
|
+
self.syncPlayerQueue(preserveCurrent: false)
|
|
95
|
+
}
|
|
96
|
+
self.rescheduleLookahead()
|
|
97
|
+
self.recomputeCapabilities()
|
|
98
|
+
self.emitQueueChange(
|
|
99
|
+
.setQueue,
|
|
100
|
+
inserted: self.tracks,
|
|
101
|
+
insertedAt: self.tracks.isEmpty ? -1 : 0
|
|
102
|
+
)
|
|
103
|
+
}, mirror: { _, generation in
|
|
104
|
+
// Push the queue to the receiver. Replacing the whole queue starts the
|
|
105
|
+
// new queue there (distinct from `handoffCurrentPlaybackToCast`, which
|
|
106
|
+
// preserves the in-flight play/pause state). A receiver-side failure
|
|
107
|
+
// needs no fallback — the local path above already ran.
|
|
108
|
+
let resolvedStart = Self.resolvedStartIndex(
|
|
109
|
+
startAtIndex: startAtIndex, trackCount: tracks.count)
|
|
110
|
+
let castItems = tracks.compactMap { CastMediaItem.from(track: $0) }
|
|
111
|
+
// Skip the cast route if EVERY track had an unparseable url
|
|
112
|
+
// (`from(track:)` returned nil). Empty queues mean nothing to play.
|
|
113
|
+
guard !tracks.isEmpty, castItems.count == tracks.count else { return }
|
|
114
|
+
// nil covers both "no receiver is active" and "the receiver-side load
|
|
115
|
+
// failed"; neither needs anything beyond the local queue already set.
|
|
116
|
+
let routed = (try? await CastTransportRouter.routeSetQueue(
|
|
117
|
+
tracks: castItems,
|
|
118
|
+
startIndex: max(0, resolvedStart),
|
|
119
|
+
startPositionMs: 0,
|
|
120
|
+
playWhenReady: true
|
|
121
|
+
)) != nil
|
|
122
|
+
guard routed else { return }
|
|
123
|
+
// Seed the start track's lockscreen metadata immediately so the surface
|
|
124
|
+
// doesn't wait for the first receiver status. The current track is
|
|
125
|
+
// resolved from the receiver's absolute queue index, not the itemIds, so
|
|
126
|
+
// no itemId map is needed here.
|
|
127
|
+
//
|
|
128
|
+
// The seed reads the live queue, so the check belongs inside this hop:
|
|
129
|
+
// a `setQueue` that landed while the load was in flight has already
|
|
130
|
+
// replaced `tracks`, and `resolvedStart` names a position in the queue
|
|
131
|
+
// that lost. That newer mutation seeds its own start track.
|
|
132
|
+
await self.onPlayerQueue {
|
|
133
|
+
guard self.queueMutationGeneration == generation else { return }
|
|
134
|
+
self.castEventBridge.seedStartMetadata(startIndex: resolvedStart)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Clamp the consumer-supplied start index into the queue bounds. An empty
|
|
140
|
+
/// queue ignores the parameter; nil, non-finite, negative and out-of-range
|
|
141
|
+
/// all coerce to 0.
|
|
142
|
+
///
|
|
143
|
+
/// The clamp happens in `Double` space because `Int(.nan)` and
|
|
144
|
+
/// `Int(.infinity)` are undefined behaviour — see `InputGuards`.
|
|
145
|
+
private static func resolvedStartIndex(
|
|
146
|
+
startAtIndex: Double?, trackCount: Int
|
|
147
|
+
) -> Int {
|
|
148
|
+
guard trackCount > 0 else { return -1 }
|
|
149
|
+
guard let requested = startAtIndex, requested.isFinite else { return 0 }
|
|
150
|
+
return Int(max(0, min(requested, Double(trackCount - 1))))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Adding to an empty queue (cur == -1) leaves cur at -1 and leaves
|
|
154
|
+
/// the player empty — consumer must call `skipToIndex(0)` to start
|
|
155
|
+
/// playback. This avoids implicit "start playing" surprises on
|
|
156
|
+
/// `addToQueue`. Non-empty queue with
|
|
157
|
+
/// insertion strictly after cur preserves the currently-playing
|
|
158
|
+
/// item surgically. Insertion before/at cur shifts cur forward;
|
|
159
|
+
/// since AVQueuePlayer holds only cur-onward items, the player
|
|
160
|
+
/// queue is unchanged by before-cur insertions (no playback gap).
|
|
161
|
+
func addToQueue(tracks newTracks: [TrackItem], insertBefore: Double?) throws -> Promise<Void> {
|
|
162
|
+
// The local mutation is enqueued **at call time** so it reaches the player
|
|
163
|
+
// in the order JS issued it, and the receiver is mirrored afterwards.
|
|
164
|
+
//
|
|
165
|
+
// An insert into the immediate-next slot of a playing queue is the one
|
|
166
|
+
// `AVQueuePlayer` prerolls on the shared render pipeline mid-playback,
|
|
167
|
+
// which briefly disturbs the current item. The insert is not held until
|
|
168
|
+
// the asset has loaded: a hold lets a transport call issued straight
|
|
169
|
+
// afterwards reach the player first and act on the pre-insert queue.
|
|
170
|
+
// Ordering wins — and in the sequence where it matters, "play next" then
|
|
171
|
+
// skip, the current item is being left anyway.
|
|
172
|
+
return enqueueThenMirror({ () -> Int in
|
|
173
|
+
let at = QueueMutationArithmetic.clampInsertBefore(
|
|
174
|
+
insertBefore: InputGuards.insertPosition(insertBefore, count: self.tracks.count),
|
|
175
|
+
trackCount: self.tracks.count
|
|
176
|
+
)
|
|
177
|
+
self.performingMutation {
|
|
178
|
+
self.addToQueueBody(newTracks: newTracks, insertBefore: insertBefore)
|
|
179
|
+
}
|
|
180
|
+
self.rescheduleLookahead()
|
|
181
|
+
self.recomputeCapabilities()
|
|
182
|
+
return at
|
|
183
|
+
}, mirror: { insertAt, _ in
|
|
184
|
+
// Mirror only when every new track is castable. A dropped item would
|
|
185
|
+
// shift the receiver queue out of alignment with the local index space
|
|
186
|
+
// that later mutations (remove/move) resolve against, so a partial set
|
|
187
|
+
// is not routed at all — the same all-or-nothing rule `setQueue` applies.
|
|
188
|
+
let castItems = newTracks.compactMap { CastMediaItem.from(track: $0) }
|
|
189
|
+
if !castItems.isEmpty, castItems.count == newTracks.count {
|
|
190
|
+
_ = await CastTransportRouter.routeAddToQueue(
|
|
191
|
+
items: castItems, beforeIndex: insertAt)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func addToQueueBody(
|
|
197
|
+
newTracks: [TrackItem],
|
|
198
|
+
insertBefore: Double?
|
|
199
|
+
) {
|
|
200
|
+
guard !newTracks.isEmpty, let engine = self.engine else { return }
|
|
201
|
+
|
|
202
|
+
// Non-finite `insertBefore` is dropped (treated as append) so
|
|
203
|
+
// `Int($0)` never traps on NaN / ±Infinity. Mirrors Android's
|
|
204
|
+
// `takeIf { it.isFinite() }?.toInt()` sanitisation at the
|
|
205
|
+
// mutation boundary.
|
|
206
|
+
let at = QueueMutationArithmetic.clampInsertBefore(
|
|
207
|
+
insertBefore: InputGuards.insertPosition(insertBefore, count: self.tracks.count),
|
|
208
|
+
trackCount: self.tracks.count
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// An insert lands in front of `at`, so that position and the one before it
|
|
212
|
+
// are what the engine would have to be told about.
|
|
213
|
+
let deferred = deferEngineWork(touching: [at - 1, at])
|
|
214
|
+
let newIds = self.queueState.insert(newTracks, at: at)
|
|
215
|
+
// Initialise retry-budget entries for the new items so they have
|
|
216
|
+
// an auto-retry budget when (if) they hit a transient error.
|
|
217
|
+
let attempts = self.effectiveAutoRetries()
|
|
218
|
+
for id in newIds {
|
|
219
|
+
self.retryAttemptsRemaining[id] = attempts
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let oldCur = self.currentTrackIndex
|
|
223
|
+
self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
|
|
224
|
+
currentIndex: oldCur, insertAt: at, count: newTracks.count
|
|
225
|
+
)
|
|
226
|
+
// Queue data model is final here (tracks + index committed); the
|
|
227
|
+
// engine insert below only touches the audio pipeline. Emit before
|
|
228
|
+
// the early returns so every non-empty add reports one snapshot.
|
|
229
|
+
self.emitQueueChange(.add, inserted: newTracks, insertedAt: at)
|
|
230
|
+
|
|
231
|
+
if oldCur < 0 {
|
|
232
|
+
// Queue was empty — player stays empty until skipToIndex.
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if at <= oldCur {
|
|
237
|
+
// Insertion at/before cur: player queue (cur-onward) is
|
|
238
|
+
// unchanged; only cur shifted forward (already applied via
|
|
239
|
+
// `currentIndexAfterAdd` above).
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A queue that has ended stays ended until a skip or a seek: the engine
|
|
244
|
+
// is left alone, whether it drained (gapless) or still holds the finished
|
|
245
|
+
// item (crossfade), and the model carries the insert until then. Behind
|
|
246
|
+
// a leg parked at its end, an inserted item would be what the next
|
|
247
|
+
// `play()` fades into.
|
|
248
|
+
if self.reachedQueueEnd { return }
|
|
249
|
+
|
|
250
|
+
// Insertion strictly after cur: surgical insert preserves the
|
|
251
|
+
// currently-playing item and any preloaded look-ahead state.
|
|
252
|
+
// Anchor on the track immediately before the insertion point,
|
|
253
|
+
// matched by lib-generated identity so it resolves in either
|
|
254
|
+
// engine's live queue (cur-onward for GaplessEngine, installed slice
|
|
255
|
+
// for CrossfadeEngine).
|
|
256
|
+
let existing = engine.allMediaItems
|
|
257
|
+
let anchorId: String? = (at - 1 >= 0 && at - 1 < self.queueItemIds.count)
|
|
258
|
+
? self.queueItemIds[at - 1] : nil
|
|
259
|
+
let anchorItem = existing.first { $0.queueItemId != nil && $0.queueItemId == anchorId }
|
|
260
|
+
|
|
261
|
+
guard let anchorItem else {
|
|
262
|
+
// The track before the insertion point is not materialised, so there
|
|
263
|
+
// is no position to insert after: falling back to the last enqueued
|
|
264
|
+
// item would play the new tracks ahead of every position between it
|
|
265
|
+
// and them, and an `insert(after: nil)` into a drained engine seats
|
|
266
|
+
// playback on the new track while `currentTrackIndex` stays on the
|
|
267
|
+
// one that finished. Leave the engine alone — `self.tracks` already
|
|
268
|
+
// carries the insert, and the queue is rebuilt from it on the way
|
|
269
|
+
// past.
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
guard !deferred else { return }
|
|
274
|
+
// Only what the window holds is built here. An add that runs past the
|
|
275
|
+
// window — a whole album dropped in behind the playing track — goes
|
|
276
|
+
// through the rebuild instead, which trims the installed tail and
|
|
277
|
+
// appends the slice; `topUpWindow` materialises the rest as the playhead
|
|
278
|
+
// advances. Inserting every added item surgically would build each one
|
|
279
|
+
// now, and leave the engine holding an unbroken run only if all of them
|
|
280
|
+
// were inserted.
|
|
281
|
+
let window = QueueWindowArithmetic.slice(
|
|
282
|
+
currentIndex: self.currentTrackIndex,
|
|
283
|
+
trackCount: self.tracks.count,
|
|
284
|
+
trail: self.engineTrail,
|
|
285
|
+
wraps: false
|
|
286
|
+
)
|
|
287
|
+
guard window.contains(at + newTracks.count - 1) else {
|
|
288
|
+
self.fullRebuildPlayerQueue()
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
let newItems = AVQueueBuilder.buildPlayerItems(
|
|
292
|
+
tracks: newTracks, queueItemIds: newIds,
|
|
293
|
+
config: self.config, server: self.routingServer)
|
|
294
|
+
let ok = engine.insertItems(newItems, after: anchorItem)
|
|
295
|
+
if !ok {
|
|
296
|
+
// Surgical insert failed — recover by rebuilding the tail
|
|
297
|
+
// so `self.tracks` and the engine queue cannot diverge.
|
|
298
|
+
self.syncPlayerQueue(preserveCurrent: true)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func removeFromQueue(indices: [Double]) throws -> Promise<Void> {
|
|
303
|
+
// Capture the sanitised indices against the pre-mutation queue and mutate
|
|
304
|
+
// local **at call time**, so the mutation reaches the player in the order
|
|
305
|
+
// JS issued it; then mirror the removal to the receiver (resolved to
|
|
306
|
+
// stable itemIds inside the cast session before removing). Inside
|
|
307
|
+
// `Promise.async` the mutation would be an unstructured Task's first
|
|
308
|
+
// statement, which a later call's Task can beat to the queue.
|
|
309
|
+
return enqueueThenMirror({
|
|
310
|
+
// Exclude the current index — the currently-playing track is
|
|
311
|
+
// pinned on the receiver too, matching the local body below.
|
|
312
|
+
let cur = self.currentTrackIndex
|
|
313
|
+
let sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
|
|
314
|
+
rawIndices: indices.compactMap {
|
|
315
|
+
InputGuards.validQueueIndex($0, count: self.tracks.count)
|
|
316
|
+
},
|
|
317
|
+
trackCount: self.tracks.count
|
|
318
|
+
).filter { $0 != cur }
|
|
319
|
+
self.performingMutation {
|
|
320
|
+
self.removeFromQueueBody(indices: indices)
|
|
321
|
+
}
|
|
322
|
+
self.rescheduleLookahead()
|
|
323
|
+
self.recomputeCapabilities()
|
|
324
|
+
return sanitised
|
|
325
|
+
}, mirror: { castIndices, _ in
|
|
326
|
+
if !castIndices.isEmpty {
|
|
327
|
+
_ = await CastTransportRouter.routeRemoveFromQueue(indices: castIndices)
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private func removeFromQueueBody(indices: [Double]) {
|
|
333
|
+
guard !indices.isEmpty, let engine = self.engine else { return }
|
|
334
|
+
// `removeIds` below force-subscripts `queueItemIds`; this asserts the
|
|
335
|
+
// parallel-array invariant the sanitiser's in-range output relies on.
|
|
336
|
+
assertQueueIdsInvariant("removeFromQueueBody")
|
|
337
|
+
|
|
338
|
+
// Bounds-check in `Double` space: `Int(_:)` from a floating-point source
|
|
339
|
+
// traps on non-finite and out-of-Int-range values, and these arrive
|
|
340
|
+
// straight from JS. See `InputGuards`.
|
|
341
|
+
var sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
|
|
342
|
+
rawIndices: indices.compactMap {
|
|
343
|
+
InputGuards.validQueueIndex($0, count: self.tracks.count)
|
|
344
|
+
},
|
|
345
|
+
trackCount: self.tracks.count
|
|
346
|
+
)
|
|
347
|
+
// The currently-playing track is pinned: it cannot be removed via
|
|
348
|
+
// removeFromQueue (a consumer that wants it gone skips off it first,
|
|
349
|
+
// then removes it). Drop the current index from the set — a request
|
|
350
|
+
// to remove only the current track is a no-op; a mixed request drops
|
|
351
|
+
// the other tracks and keeps the current one.
|
|
352
|
+
sanitised.removeAll { $0 == self.currentTrackIndex }
|
|
353
|
+
guard !sanitised.isEmpty else { return }
|
|
354
|
+
|
|
355
|
+
let trackCountBefore = self.tracks.count
|
|
356
|
+
let oldCur = self.currentTrackIndex
|
|
357
|
+
|
|
358
|
+
// Match the AVPlayerItems to drop by their lib-generated identity
|
|
359
|
+
// rather than by index offset, so the removal is engine-agnostic:
|
|
360
|
+
// GaplessEngine exposes only the cur-onward slice (consumed items
|
|
361
|
+
// are gone), while CrossfadeEngine exposes its installed slice. Captured
|
|
362
|
+
// before the `queueState.remove` loop below mutates the id array.
|
|
363
|
+
// Decided here, while the positions still name the tracks being removed.
|
|
364
|
+
let deferred = deferEngineWork(touching: sanitised)
|
|
365
|
+
let removeIds = Set(sanitised.map { self.queueItemIds[$0] })
|
|
366
|
+
let itemsToRemove = deferred ? [] : engine.allMediaItems.filter { item in
|
|
367
|
+
guard let id = item.queueItemId else { return false }
|
|
368
|
+
return removeIds.contains(id)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Iterate in descending order so earlier indices stay valid as
|
|
372
|
+
// each removal shifts the remainder. Drop the matching
|
|
373
|
+
// retry-budget entry so the map doesn't grow stale.
|
|
374
|
+
for i in sanitised.reversed() {
|
|
375
|
+
let removedId = self.queueState.remove(at: i)
|
|
376
|
+
self.retryAttemptsRemaining.removeValue(forKey: removedId)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// The current track is never in the removed set, so it keeps playing
|
|
380
|
+
// and its identity is unchanged; only its index shifts down by the
|
|
381
|
+
// number of removed earlier tracks.
|
|
382
|
+
self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
|
|
383
|
+
currentIndex: oldCur,
|
|
384
|
+
sanitisedIndices: sanitised,
|
|
385
|
+
trackCountBefore: trackCountBefore
|
|
386
|
+
)
|
|
387
|
+
// Reached only past the `sanitised.isEmpty` guard, so ≥1 track was
|
|
388
|
+
// removed. tracks + index are final; the engine.remove loop below
|
|
389
|
+
// only detaches the AVPlayerItems.
|
|
390
|
+
self.emitQueueChange(.remove, removed: sanitised)
|
|
391
|
+
|
|
392
|
+
// The currentItem is untouched (never in `itemsToRemove`); removing
|
|
393
|
+
// only later/earlier items leaves it playing in place.
|
|
394
|
+
for item in itemsToRemove {
|
|
395
|
+
engine.remove(item)
|
|
396
|
+
}
|
|
397
|
+
guard !deferred else { return }
|
|
398
|
+
// Removing the queued-ahead tracks takes them straight out of the engine,
|
|
399
|
+
// so the run can be left at the playing item with the queue far from over.
|
|
400
|
+
self.topUpWindow()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
func moveInQueue(fromIndex: Double, toIndex: Double) throws -> Promise<Void> {
|
|
404
|
+
// Capture the validated from/to against the pre-mutation queue and mutate
|
|
405
|
+
// local **at call time**, so the mutation reaches the player in JS call
|
|
406
|
+
// order; then mirror the reorder to the receiver.
|
|
407
|
+
return enqueueThenMirror({ () -> (from: Int, to: Int)? in
|
|
408
|
+
let count = self.tracks.count
|
|
409
|
+
// Mirrored to the receiver only when the move is one the local body
|
|
410
|
+
// will also perform; the body applies the same rules itself.
|
|
411
|
+
var resolved: (from: Int, to: Int)?
|
|
412
|
+
if let from = InputGuards.validQueueIndex(fromIndex, count: count),
|
|
413
|
+
let to = InputGuards.validQueueIndex(toIndex, count: count),
|
|
414
|
+
from != to, from != self.currentTrackIndex {
|
|
415
|
+
resolved = (from, to)
|
|
416
|
+
}
|
|
417
|
+
self.performingMutation {
|
|
418
|
+
self.moveInQueueBody(fromIndex: fromIndex, toIndex: toIndex)
|
|
419
|
+
}
|
|
420
|
+
self.rescheduleLookahead()
|
|
421
|
+
self.recomputeCapabilities()
|
|
422
|
+
return resolved
|
|
423
|
+
}, mirror: { move, _ in
|
|
424
|
+
if let move = move {
|
|
425
|
+
_ = await CastTransportRouter.routeMoveInQueue(fromIndex: move.from, toIndex: move.to)
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private func moveInQueueBody(fromIndex: Double, toIndex: Double) {
|
|
431
|
+
guard self.engine != nil else { return }
|
|
432
|
+
// Bounds-check in `Double` space: `Int(_:)` from a floating-point source
|
|
433
|
+
// traps on non-finite and out-of-Int-range values, and these arrive
|
|
434
|
+
// straight from JS. See `InputGuards`.
|
|
435
|
+
let count = self.tracks.count
|
|
436
|
+
guard let from = InputGuards.validQueueIndex(fromIndex, count: count),
|
|
437
|
+
let to = InputGuards.validQueueIndex(toIndex, count: count),
|
|
438
|
+
// The currently-playing track cannot be reordered — only the tracks
|
|
439
|
+
// around it move. Reordering the current item would force a full
|
|
440
|
+
// rebuild that restarts the active track.
|
|
441
|
+
from != to,
|
|
442
|
+
from != self.currentTrackIndex else { return }
|
|
443
|
+
|
|
444
|
+
// Both ends and the neighbours the item lands between, decided before the
|
|
445
|
+
// model changes while those positions still name the same tracks. A move
|
|
446
|
+
// is a remove then an insert, so a forward move lands between the
|
|
447
|
+
// pre-mutation `to` and `to + 1` while a backward one lands between
|
|
448
|
+
// `to - 1` and `to`; both neighbourhoods are covered rather than reasoned
|
|
449
|
+
// about per direction.
|
|
450
|
+
let deferred = deferEngineWork(touching: [from, to - 1, to, to + 1])
|
|
451
|
+
let oldCur = self.currentTrackIndex
|
|
452
|
+
self.queueState.move(from: from, to: to)
|
|
453
|
+
|
|
454
|
+
self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
|
|
455
|
+
currentIndex: oldCur, fromIndex: from, toIndex: to
|
|
456
|
+
)
|
|
457
|
+
let newCur = self.currentTrackIndex
|
|
458
|
+
|
|
459
|
+
// Decide engine-queue adjustment:
|
|
460
|
+
// * cur unchanged (oldCur == newCur) AND cur track wasn't
|
|
461
|
+
// moved: engine queue is stale only in the tail; rebuild
|
|
462
|
+
// tail while preserving currentItem.
|
|
463
|
+
// * cur moved (from == oldCur) OR cur's physical position
|
|
464
|
+
// shifted: reconcile against the whole slice. The rebuild keeps the
|
|
465
|
+
// leading run it can, so the playing track survives a move that
|
|
466
|
+
// leaves it at the head.
|
|
467
|
+
let preserveCurrent = (from != oldCur) && (newCur == oldCur)
|
|
468
|
+
// Emitted before the early return, like add and remove: the queue model is
|
|
469
|
+
// reordered whether or not the engine has been told yet, and a consumer
|
|
470
|
+
// rendering from this event would otherwise show the old order.
|
|
471
|
+
self.emitQueueChange(.move, movedFrom: from, movedTo: to)
|
|
472
|
+
guard !deferred else { return }
|
|
473
|
+
// A queue that has ended stays ended until a skip, a seek or a fresh
|
|
474
|
+
// queue: the model carries the move and the rebuild those run picks it
|
|
475
|
+
// up. Reconciling now would put a track behind the parked leg.
|
|
476
|
+
if self.reachedQueueEnd { return }
|
|
477
|
+
// performingMutation so the crossfade engine's setItems →
|
|
478
|
+
// engineDidTransitionTrack doesn't clobber the move-computed
|
|
479
|
+
// currentTrackIndex with an index resolved from the engine mid-rebuild (mirrors the
|
|
480
|
+
// setQueue paths, which already mutate under this guard).
|
|
481
|
+
self.performingMutation {
|
|
482
|
+
self.syncPlayerQueue(preserveCurrent: preserveCurrent)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
func clearQueue() throws -> Promise<Void> {
|
|
488
|
+
// Enqueued at call time like every other queue mutation, so a `setQueue`
|
|
489
|
+
// issued straight afterwards cannot be cleared by this one landing late.
|
|
490
|
+
return enqueueThenMirror({
|
|
491
|
+
let queueWasNonEmpty = !self.tracks.isEmpty
|
|
492
|
+
self.performingMutation {
|
|
493
|
+
self.engine?.removeAllItems()
|
|
494
|
+
self.queueState.clear()
|
|
495
|
+
self.currentTrackIndex = -1
|
|
496
|
+
self.currentTrackSource = nil
|
|
497
|
+
// No rearm here — the next `setQueue` goes through
|
|
498
|
+
// `fullRebuildPlayerQueue` which rearms before inserts,
|
|
499
|
+
// so the clean-slate initial-buffer phase is preserved.
|
|
500
|
+
}
|
|
501
|
+
// `performingMutation` suppresses the `\.currentItem` → nil
|
|
502
|
+
// KVO fire; emit a synthetic trackChange here so JS hooks
|
|
503
|
+
// (e.g. `useActiveTrack`) clear their (track, index) snapshot.
|
|
504
|
+
// Mirrors Android `clearQueueInternal` which fires
|
|
505
|
+
// `(null, -1, QUEUE_REPLACED)` via Media3's onMediaItemTransition
|
|
506
|
+
// → `handleMediaItemTransition`. Reset `lastEmittedQueueItemId`
|
|
507
|
+
// so a subsequent `setQueue` re-emits cleanly via the dedup
|
|
508
|
+
// gate at `handleCurrentItemDidChange`.
|
|
509
|
+
self.lastEmittedQueueItemId = nil
|
|
510
|
+
self.lastErrorQueueItemId = nil
|
|
511
|
+
self.lastErrorCode = nil
|
|
512
|
+
self.retryAttemptsRemaining.removeAll()
|
|
513
|
+
// Fire queue-change BEFORE the synthetic track-change so a
|
|
514
|
+
// `useQueue()` consumer sees the empty queue + index -1 together,
|
|
515
|
+
// not a one-frame (old queue, index -1) transient. Only when the
|
|
516
|
+
// queue was actually non-empty — clearing an empty queue is a no-op.
|
|
517
|
+
if queueWasNonEmpty { self.emitQueueChange(.clear) }
|
|
518
|
+
self.trackChangeListeners.forEach { $0(nil, Double(-1), .queueReplaced, nil) }
|
|
519
|
+
self.nowPlayingInfo.refreshAll(self.nowPlayingSnapshot())
|
|
520
|
+
self.artworkResolver.cancelInFlight()
|
|
521
|
+
// Drop any cached now-playing format + emit null so JS-side
|
|
522
|
+
// consumers of `onNowPlayingFormatChange` see the queue-cleared
|
|
523
|
+
// state without waiting for the engine's `currentMediaItem` to
|
|
524
|
+
// settle (it can briefly retain the last item post-removeAll).
|
|
525
|
+
self.refreshNowPlayingFormatForActiveItem()
|
|
526
|
+
self.rescheduleLookahead()
|
|
527
|
+
self.recomputeCapabilities()
|
|
528
|
+
// The queue is empty now → settle buffer state + fully-buffered to their
|
|
529
|
+
// empty defaults and emit the transition. The `\.currentItem → nil` KVO
|
|
530
|
+
// is suppressed under `performingMutation`, so recompute explicitly.
|
|
531
|
+
self.hasStartedPlaying = false
|
|
532
|
+
self.wantsToPlay = false
|
|
533
|
+
self.recomputeBufferState()
|
|
534
|
+
self.recomputeFullyBuffered()
|
|
535
|
+
}, mirror: { _, _ in
|
|
536
|
+
// Mirror the clear to the receiver (removes every receiver queue item).
|
|
537
|
+
_ = await CastTransportRouter.routeClearQueue()
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
func getQueue() throws -> Promise<[TrackItem]> {
|
|
542
|
+
return enqueueValue { self.tracks }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// Sync the engine's queue with `self.tracks[currentTrackIndex...]`.
|
|
546
|
+
///
|
|
547
|
+
/// When `preserveCurrent` is true AND the engine has a currentItem,
|
|
548
|
+
/// that item is kept in place and only the tail (items after
|
|
549
|
+
/// current) is rebuilt — avoids a playback restart of the current
|
|
550
|
+
/// track. Used for mutations that leave the currently-playing
|
|
551
|
+
/// track untouched (e.g. remove-after-cur, move-entirely-after-cur).
|
|
552
|
+
///
|
|
553
|
+
/// When false (or when there is no currentItem), reconciles against the
|
|
554
|
+
/// whole slice. That only restarts the current track when the slice actually
|
|
555
|
+
/// diverges at its head — a rebuild keeps whatever leading run the engine
|
|
556
|
+
/// already holds.
|
|
557
|
+
internal func syncPlayerQueue(preserveCurrent: Bool) {
|
|
558
|
+
assertQueueIdsInvariant("syncPlayerQueue")
|
|
559
|
+
guard let engine = self.engine else { return }
|
|
560
|
+
guard self.currentTrackIndex >= 0,
|
|
561
|
+
self.currentTrackIndex < self.tracks.count else {
|
|
562
|
+
engine.removeAllItems()
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The branch resolves before the tail build: `fullRebuildPlayerQueue`
|
|
567
|
+
// constructs `tracks[cur...]` itself, and every constructed item starts
|
|
568
|
+
// an `automaticallyLoadedAssetKeys` load plus, on a cache hit, a `touch`
|
|
569
|
+
// that queues an index-plist write. Keep this guard above the build.
|
|
570
|
+
// Anchor on the leg being heard, not on `currentMediaItem` — that switches
|
|
571
|
+
// to the incoming leg the moment a fade starts, so the trim below would
|
|
572
|
+
// remove the item actually playing. A fade in flight goes to the full
|
|
573
|
+
// rebuild, which resolves the anchor by identity and guards the trim.
|
|
574
|
+
guard preserveCurrent, !engine.isFadePendingOrActive,
|
|
575
|
+
let currentItem = engine.leadingQueueItemId.flatMap({ id in
|
|
576
|
+
engine.allMediaItems.first { $0.queueItemId == id }
|
|
577
|
+
})
|
|
578
|
+
else {
|
|
579
|
+
return self.fullRebuildPlayerQueue()
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Build the tail post-currentItem. The origin comes from the engine's
|
|
583
|
+
// own item: `currentTrackIndex` is resynced a main-queue hop later, so
|
|
584
|
+
// reading it here can place the tail one position out and leave the
|
|
585
|
+
// engine's run non-contiguous.
|
|
586
|
+
let currentPosition = matchTrackIndex(forCurrentItem: currentItem) ?? self.currentTrackIndex
|
|
587
|
+
let tailStart = currentPosition + 1
|
|
588
|
+
// Bounded to the floor on both engines, so a move on a long queue builds
|
|
589
|
+
// at most a window of items. The trail is not rebuilt through this path;
|
|
590
|
+
// the next reconcile restores it.
|
|
591
|
+
let tailEnd = min(currentPosition + QueueWindowArithmetic.floor, self.tracks.count)
|
|
592
|
+
let tailTracks = Array(self.tracks[tailStart ..< tailEnd])
|
|
593
|
+
let tailIds = Array(self.queueItemIds[tailStart ..< tailEnd])
|
|
594
|
+
let tailItems = AVQueueBuilder.buildPlayerItems(
|
|
595
|
+
tracks: tailTracks, queueItemIds: tailIds,
|
|
596
|
+
config: self.config, server: self.routingServer)
|
|
597
|
+
|
|
598
|
+
let dropped = engine.allMediaItems.filter { $0 !== currentItem }
|
|
599
|
+
AVQueueBuilder.cancelLoading(dropping: dropped, keeping: [currentItem] + tailItems)
|
|
600
|
+
for item in dropped {
|
|
601
|
+
engine.remove(item)
|
|
602
|
+
}
|
|
603
|
+
if !engine.insertItems(tailItems, after: currentItem) {
|
|
604
|
+
// Couldn't insert into the live queue — fall back to a full
|
|
605
|
+
// rebuild so authoritative state + player converge.
|
|
606
|
+
self.fullRebuildPlayerQueue()
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/// Whether the engine's items can be left alone until the fade settles.
|
|
611
|
+
///
|
|
612
|
+
/// Any change to the installed items cancels an in-flight fade, so a
|
|
613
|
+
/// mutation that touches neither leg is held back rather than cutting a
|
|
614
|
+
/// crossfade short for a part of the queue nobody is hearing. One that does
|
|
615
|
+
/// touch a leg cannot wait: letting the arm chain ride on through would
|
|
616
|
+
/// fade into a track the queue no longer has.
|
|
617
|
+
internal func deferEngineWork(touching positions: [Int]) -> Bool {
|
|
618
|
+
guard let engine = self.engine, engine.isFadePendingOrActive else { return false }
|
|
619
|
+
let attached = Set(engine.attachedQueueItemIds)
|
|
620
|
+
let touched = positions.compactMap { self.queueItemIds[safe: $0] }
|
|
621
|
+
// No resolvable position means nothing is known about what this touches,
|
|
622
|
+
// which is not the same as knowing it touches nothing.
|
|
623
|
+
guard !touched.isEmpty else { return false }
|
|
624
|
+
guard touched.allSatisfy({ !attached.contains($0) }) else { return false }
|
|
625
|
+
self.engineReconcileDeferred = true
|
|
626
|
+
return true
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/// Apply a mutation the engine was not told about while a fade was running.
|
|
630
|
+
///
|
|
631
|
+
/// Called when the fade settles, either way it settles. The reconcile
|
|
632
|
+
/// re-derives everything from the model, so it does not matter how many
|
|
633
|
+
/// mutations were held back or in what order they arrived.
|
|
634
|
+
internal func flushDeferredEngineWork() {
|
|
635
|
+
guard self.engineReconcileDeferred else { return }
|
|
636
|
+
// A fade that is still pending has not settled; the work keeps waiting.
|
|
637
|
+
guard self.engine?.isFadePendingOrActive != true else { return }
|
|
638
|
+
// Every engine mutation cancels a fade, and a cancel settles one, so this
|
|
639
|
+
// is reachable from inside a rebuild that is part-way through its own
|
|
640
|
+
// trim-and-append. Rebuilding now would run against the outer pass's stale
|
|
641
|
+
// view of the installed run and could append a second copy of an item it
|
|
642
|
+
// still believes it holds. Keep the work owed and take it on the next hop,
|
|
643
|
+
// when the outer mutation has finished.
|
|
644
|
+
guard !self.isMutatingPlayerQueue else {
|
|
645
|
+
self.playerQueue.async { [weak self] in self?.flushDeferredEngineWork() }
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
self.engineReconcileDeferred = false
|
|
649
|
+
self.performingMutation { self.fullRebuildPlayerQueue() }
|
|
650
|
+
self.rescheduleLookahead()
|
|
651
|
+
self.recomputeCapabilities()
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/// How many played positions the engine keeps behind the playhead.
|
|
655
|
+
///
|
|
656
|
+
/// The gapless `AVQueuePlayer` consumes finished items, so it can hold none
|
|
657
|
+
/// and asking for a trail there would be asking for items it drops anyway.
|
|
658
|
+
/// The crossfade engine retains its whole installed run, so without a trail
|
|
659
|
+
/// the run only grows — which moves the memory peak later rather than
|
|
660
|
+
/// lowering it. Two positions buys `AVURLAsset` reuse for an immediate
|
|
661
|
+
/// backward skip and nothing beyond that, which is why it is not deeper.
|
|
662
|
+
internal var engineTrail: Int {
|
|
663
|
+
self.player != nil ? 0 : 2
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/// Bring the engine's items in line with the positions it should be holding.
|
|
667
|
+
///
|
|
668
|
+
/// The positions come from `QueueWindowArithmetic.slice`, which is the one
|
|
669
|
+
/// answer to "what should be installed" — a trail behind the playhead and
|
|
670
|
+
/// the floor ahead of it, stopping at the last position whatever the repeat
|
|
671
|
+
/// mode; the wrap is decided on the drain path by `applyEndVerdict`.
|
|
672
|
+
///
|
|
673
|
+
/// Reconciling rather than reinstalling: positions that fell off the back of
|
|
674
|
+
/// the trail are removed, the run the engine already holds correctly is
|
|
675
|
+
/// kept, and only the rest is built. Installing the whole slice would
|
|
676
|
+
/// discard the item already buffered, which is audible as a cut when this
|
|
677
|
+
/// lands at a boundary.
|
|
678
|
+
///
|
|
679
|
+
/// `forcingReinstall` is for the callers that rebuild in order to replace a
|
|
680
|
+
/// *specific* item rather than to follow a model change — the retry paths,
|
|
681
|
+
/// which exist to construct a fresh item over one that has failed
|
|
682
|
+
/// terminally. A failed item keeps its `queueItemId`, so identity cannot
|
|
683
|
+
/// tell a dead item from a live one and the caller has to say.
|
|
684
|
+
internal func fullRebuildPlayerQueue(forcingReinstall: Bool = false) {
|
|
685
|
+
assertQueueIdsInvariant("fullRebuildPlayerQueue")
|
|
686
|
+
guard let engine = self.engine else { return }
|
|
687
|
+
guard self.currentTrackIndex >= 0,
|
|
688
|
+
self.currentTrackIndex < self.tracks.count else {
|
|
689
|
+
engine.removeAllItems()
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
let cur = self.currentTrackIndex
|
|
693
|
+
// `wraps: false` deliberately. Carrying the head into the run at the tail
|
|
694
|
+
// would let a repeat-queue boundary crossfade instead of cutting, but it
|
|
695
|
+
// also moves the wrap off the drain path: the engine advances into the
|
|
696
|
+
// wrap target itself instead of reporting that it ran out, and
|
|
697
|
+
// `applyEndVerdict` never decides the wrap. The run stays forward-only;
|
|
698
|
+
// `slice` carries the arithmetic for a wrapping run.
|
|
699
|
+
let positions = QueueWindowArithmetic.slice(
|
|
700
|
+
currentIndex: cur,
|
|
701
|
+
trackCount: self.tracks.count,
|
|
702
|
+
trail: self.engineTrail,
|
|
703
|
+
wraps: false
|
|
704
|
+
)
|
|
705
|
+
guard !positions.isEmpty else {
|
|
706
|
+
engine.removeAllItems()
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
let slice = positions.map { self.tracks[$0] }
|
|
710
|
+
let sliceIds = positions.map { self.queueItemIds[$0] }
|
|
711
|
+
let currentId = self.queueItemIds[cur]
|
|
712
|
+
|
|
713
|
+
let installed = engine.allMediaItems
|
|
714
|
+
// Where the item being heard sits in the run. The trim must never reach
|
|
715
|
+
// it: removing it replaces the leading leg's current item, which is a cut
|
|
716
|
+
// in the middle of a track.
|
|
717
|
+
let leading = engine.leadingQueueItemId.flatMap { id in
|
|
718
|
+
installed.firstIndex { $0.queueItemId == id }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Anchor on the first wanted position the engine actually holds, but never
|
|
722
|
+
// past the current one. The trail is an optimisation — a missing trail
|
|
723
|
+
// position is not worth reinstalling the run and restarting what is
|
|
724
|
+
// playing — while a missing current position is exactly what a reinstall
|
|
725
|
+
// is for.
|
|
726
|
+
let curOffset = positions.firstIndex(of: cur) ?? 0
|
|
727
|
+
var anchor = 0
|
|
728
|
+
while anchor < curOffset,
|
|
729
|
+
!installed.contains(where: { $0.queueItemId == sliceIds[anchor] }) {
|
|
730
|
+
anchor += 1
|
|
731
|
+
}
|
|
732
|
+
let wantedIds = Array(sliceIds[anchor...])
|
|
733
|
+
let wantedTracks = Array(slice[anchor...])
|
|
734
|
+
|
|
735
|
+
// Positions that have fallen off the back of the run come off the front of
|
|
736
|
+
// what is installed. A run that has rotated past the leading item cannot
|
|
737
|
+
// be expressed as a trim plus an append, so it is reinstalled instead.
|
|
738
|
+
let fellOut = installed.firstIndex { $0.queueItemId == wantedIds.first }
|
|
739
|
+
let rotatedPastLeading = fellOut != nil && leading != nil && fellOut! > leading!
|
|
740
|
+
let retained = (forcingReinstall || rotatedPastLeading)
|
|
741
|
+
? []
|
|
742
|
+
: fellOut.map { Array(installed[$0...]) } ?? []
|
|
743
|
+
let keep = QueueMutationArithmetic.commonPrefixLength(
|
|
744
|
+
current: retained.map { $0.queueItemId }, desired: wantedIds)
|
|
745
|
+
|
|
746
|
+
// Already holding exactly this run: touching it would only throw away the
|
|
747
|
+
// buffer it has built.
|
|
748
|
+
if keep == wantedIds.count, keep == retained.count, fellOut == 0, anchor == 0 {
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Nothing worth keeping — install the run whole.
|
|
753
|
+
guard keep > 0 else {
|
|
754
|
+
reinstallWholeSlice(
|
|
755
|
+
on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// The divergent tail must not contain the item being heard. Removing it
|
|
760
|
+
// replaces the leading leg's current item, which stops playback and
|
|
761
|
+
// restores no rate, so the engine sits paused with the transport still
|
|
762
|
+
// wanting to play. Installing the run whole is the destructive option, but
|
|
763
|
+
// it is the one that puts the rate back.
|
|
764
|
+
//
|
|
765
|
+
// `retained` is `installed[fellOut...]`, so the kept prefix ends at
|
|
766
|
+
// `installed[fellOut + keep]` and the tail is everything from there on.
|
|
767
|
+
let tailStart = (fellOut ?? 0) + keep
|
|
768
|
+
guard leading.map({ $0 < tailStart }) ?? true else {
|
|
769
|
+
reinstallWholeSlice(
|
|
770
|
+
on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Cancel before releasing: `AVURLAsset.dealloc` blocks the thread that
|
|
775
|
+
// releases it while it cancels in-flight key loads, and that thread is the
|
|
776
|
+
// one serialising player state. Every wholesale path already does this;
|
|
777
|
+
// the surgical trim has to as well or a window slide that drops a
|
|
778
|
+
// still-loading remote item stalls the queue behind it.
|
|
779
|
+
let dropped = Array(installed[..<(fellOut ?? 0)]) + Array(retained[keep...])
|
|
780
|
+
AVQueueBuilder.cancelLoading(dropping: dropped, keeping: Array(retained[..<keep]))
|
|
781
|
+
if let fellOut {
|
|
782
|
+
for item in installed[..<fellOut] {
|
|
783
|
+
engine.remove(item)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for item in retained[keep...] {
|
|
787
|
+
engine.remove(item)
|
|
788
|
+
}
|
|
789
|
+
guard keep < wantedIds.count else { return }
|
|
790
|
+
let tail = AVQueueBuilder.buildPlayerItems(
|
|
791
|
+
tracks: Array(wantedTracks[keep...]), queueItemIds: Array(wantedIds[keep...]),
|
|
792
|
+
config: self.config, server: self.routingServer)
|
|
793
|
+
guard !tail.isEmpty else { return }
|
|
794
|
+
if !engine.insertItems(tail, after: retained[keep - 1]) {
|
|
795
|
+
// The engine refused the surgical insert, so install the whole run
|
|
796
|
+
// instead. Rebuilding the tail's items a second time here is the cost of
|
|
797
|
+
// a path that only runs when the engine has already said no.
|
|
798
|
+
reinstallWholeSlice(
|
|
799
|
+
on: engine, slice: slice, sliceIds: sliceIds, startingAt: currentId)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// Drain the engine and install the whole run, seated on the current
|
|
804
|
+
/// position.
|
|
805
|
+
///
|
|
806
|
+
/// The only path that seats a fresh current item, which is why the
|
|
807
|
+
/// gapless-flip rearm lives here: arming it on a rebuild that installs
|
|
808
|
+
/// nothing would leave the one-shot armed with no `\.readyToPlay` coming to
|
|
809
|
+
/// disarm it, and stalling would stay on for the rest of the track.
|
|
810
|
+
private func reinstallWholeSlice(
|
|
811
|
+
on engine: PlaybackEngine, slice: [TrackItem], sliceIds: [String],
|
|
812
|
+
startingAt currentId: String
|
|
813
|
+
) {
|
|
814
|
+
rearmGaplessFlip()
|
|
815
|
+
let items = AVQueueBuilder.buildPlayerItems(
|
|
816
|
+
tracks: slice, queueItemIds: sliceIds,
|
|
817
|
+
config: self.config, server: self.routingServer)
|
|
818
|
+
guard let startIndex = Self.seatIndex(in: items, sliceIds: sliceIds, currentId: currentId)
|
|
819
|
+
else {
|
|
820
|
+
// Nothing at or after the current position could be built. Seating on
|
|
821
|
+
// the trail would replay a track already heard; installing nothing
|
|
822
|
+
// leaves the engine drained, which is what the end verdict reads.
|
|
823
|
+
engine.removeAllItems()
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
// Reinstalling the run the engine is already rendering from would restart
|
|
827
|
+
// the track being heard, so carry its playhead across. Only when the item
|
|
828
|
+
// the run seats on is that same one: any other start item is a track
|
|
829
|
+
// change, which begins at zero.
|
|
830
|
+
let position =
|
|
831
|
+
items[startIndex].queueItemId == engine.leadingQueueItemId
|
|
832
|
+
? engine.currentPositionSeconds : 0
|
|
833
|
+
engine.setItems(
|
|
834
|
+
items, startIndex: startIndex, startPositionSeconds: position)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/// Where in a built run the engine starts, or nil when nothing at or after
|
|
838
|
+
/// the current position could be built.
|
|
839
|
+
///
|
|
840
|
+
/// The run can start behind the playhead, so the engine is told where in it
|
|
841
|
+
/// to begin. Resolved against the built items rather than the positions: an
|
|
842
|
+
/// item whose URL cannot be built is dropped, which shifts everything after
|
|
843
|
+
/// it. When the current position is one of the dropped ones, the next
|
|
844
|
+
/// position in the run that did build is the seat — playing forwards from an
|
|
845
|
+
/// unplayable track, rather than dropping back into the trail behind it.
|
|
846
|
+
internal static func seatIndex(
|
|
847
|
+
in items: [AVPlayerItem], sliceIds: [String], currentId: String
|
|
848
|
+
) -> Int? {
|
|
849
|
+
if let exact = items.firstIndex(where: { $0.queueItemId == currentId }) {
|
|
850
|
+
return exact
|
|
851
|
+
}
|
|
852
|
+
guard let curOffset = sliceIds.firstIndex(of: currentId) else { return nil }
|
|
853
|
+
let ahead = Set(sliceIds[curOffset...])
|
|
854
|
+
return items.firstIndex { item in item.queueItemId.map(ahead.contains) ?? false }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/// Destructive shuffle. Mutates the current queue in-place via
|
|
858
|
+
/// Fisher-Yates, resets `currentTrackIndex` to 0, and resumes
|
|
859
|
+
/// playback on the new tracks[0] if the player was playing
|
|
860
|
+
/// pre-shuffle. Returns the post-shuffle snapshot so the consumer
|
|
861
|
+
/// app can immediately reflect the new ordering in its UI.
|
|
862
|
+
///
|
|
863
|
+
/// The pre-shuffle ordering is not preserved; once shuffled the
|
|
864
|
+
/// queue stays in the new order until the consumer calls
|
|
865
|
+
/// `setQueue` to install a fresh ordering.
|
|
866
|
+
///
|
|
867
|
+
/// Empty queue → returns `{ tracks: [], currentIndex: -1, currentTrack: nil }`
|
|
868
|
+
/// without mutating state.
|
|
869
|
+
func shuffleQueue() throws -> Promise<ShuffleResult> {
|
|
870
|
+
// Enqueued at call time like every other queue mutation, so a mutation
|
|
871
|
+
// issued straight afterwards resolves against the shuffled order.
|
|
872
|
+
return enqueueValue { () -> ShuffleResult in
|
|
873
|
+
var result = ShuffleResult(
|
|
874
|
+
tracks: [], currentIndex: -1, currentTrack: nil)
|
|
875
|
+
// Capture playing state BEFORE the mutation so we know whether to
|
|
876
|
+
// resume after the rebuild. The transport's intent rather than the
|
|
877
|
+
// engine's audible state: a leg that is buffering, or mid-crossfade
|
|
878
|
+
// with its outgoing item finished, reports not-playing while the
|
|
879
|
+
// consumer is still playing.
|
|
880
|
+
let wasPlaying = self.wantsToPlay
|
|
881
|
+
|
|
882
|
+
if self.tracks.isEmpty {
|
|
883
|
+
// Empty queue — leave state untouched, return the empty snapshot.
|
|
884
|
+
return result
|
|
885
|
+
}
|
|
886
|
+
guard self.engine != nil else {
|
|
887
|
+
// No engine attached (configure() not yet called). Bail with
|
|
888
|
+
// empty result rather than silently mutating tracks but not
|
|
889
|
+
// the player — the prior shape returned a "shuffled"
|
|
890
|
+
// snapshot while leaving self.tracks unchanged, which lied
|
|
891
|
+
// to the consumer. Gate on `self.engine` (canonical lib-
|
|
892
|
+
// initialised sentinel); `self.player` is gapless-only and
|
|
893
|
+
// nil under CrossfadeEngine.
|
|
894
|
+
return result
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Shuffle positions rather than tracks: the permutation is what
|
|
898
|
+
// onQueueChange reports, and it cannot be recovered from the reordered
|
|
899
|
+
// list afterwards. Fisher-Yates via Swift stdlib (Apple-documented).
|
|
900
|
+
var order = Array(self.tracks.indices)
|
|
901
|
+
order.shuffle()
|
|
902
|
+
let newTracks = order.map { self.tracks[$0] }
|
|
903
|
+
|
|
904
|
+
self.performingMutation {
|
|
905
|
+
// Mirrors setQueue — `replaceAll` regenerates queueItemIds
|
|
906
|
+
// so each new AVPlayerItem gets a unique lib-internal
|
|
907
|
+
// identity and the destructive shuffle re-keys every
|
|
908
|
+
// position.
|
|
909
|
+
let newIds = self.queueState.replaceAll(newTracks)
|
|
910
|
+
self.currentTrackIndex = 0
|
|
911
|
+
self.pendingTrackChangeReason = .queueReplaced
|
|
912
|
+
// Re-keyed and re-seated at 0: not over, whatever it was before.
|
|
913
|
+
self.reachedQueueEnd = false
|
|
914
|
+
let attempts = self.effectiveAutoRetries()
|
|
915
|
+
self.retryAttemptsRemaining = Dictionary(
|
|
916
|
+
uniqueKeysWithValues: newIds.map { ($0, attempts) }
|
|
917
|
+
)
|
|
918
|
+
self.fullRebuildPlayerQueue()
|
|
919
|
+
}
|
|
920
|
+
self.rescheduleLookahead()
|
|
921
|
+
self.recomputeCapabilities()
|
|
922
|
+
// Skip a single-track shuffle — the order can't change.
|
|
923
|
+
if self.tracks.count > 1 { self.emitQueueChange(.shuffle, order: order) }
|
|
924
|
+
|
|
925
|
+
// Resume playback if the user was playing before the shuffle.
|
|
926
|
+
// Stays paused otherwise — consumer can call play() if they
|
|
927
|
+
// want to start from the new track 0.
|
|
928
|
+
if wasPlaying, !self.isInterrupted {
|
|
929
|
+
self.pendingStateChangeReason = .user
|
|
930
|
+
self.beginPlayback()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
result = ShuffleResult(
|
|
934
|
+
tracks: self.tracks,
|
|
935
|
+
currentIndex: 0,
|
|
936
|
+
currentTrack: self.tracks[0])
|
|
937
|
+
return result
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|