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.
- package/QueuePlayer.podspec +14 -9
- package/README.md +3 -3
- package/android/build.gradle +7 -0
- package/android/consumer-rules.pro +72 -3
- 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 +26 -22
- 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 +20 -72
- 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 +870 -278
- 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/CastEventBridge.kt +1 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
- 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 +23 -12
- 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 +58 -21
- 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/AirPlaySession.kt +26 -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/MetadataSyncTarget.kt +20 -0
- 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/chromecast/ChromecastSession.kt +73 -13
- 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 +23 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
- 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 +8 -4
- 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 +68 -0
- 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 +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
- 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 +27 -11
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
- 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/Chromecast/ChromecastSession.swift +90 -11
- package/ios/Cast/Core/CastEventBridge.swift +66 -26
- package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
- package/ios/Cast/Core/CastSession.swift +11 -0
- 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 +5 -0
- 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/PlaybackStateRouterTests.swift +1 -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/TrackPlayerCastStateTests.swift +78 -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 -4610
- 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/airplay/AirPlay2MetadataSync.kt +0 -189
- 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,196 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
extension TrackPlayer {
|
|
4
|
+
// MARK: - Auto-retry + stuck-recovery
|
|
5
|
+
|
|
6
|
+
/// Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
|
|
7
|
+
/// defaults to 1, clamps to [0, 5]. Called from `setQueue` /
|
|
8
|
+
/// `addToQueueBody` to populate `retryAttemptsRemaining`.
|
|
9
|
+
///
|
|
10
|
+
/// Non-finite (NaN / ±Infinity) inputs from JS would trap at
|
|
11
|
+
/// `Int(Double.nan)` — Swift's Int initialiser preconditionFails
|
|
12
|
+
/// rather than rounding to 0 like Kotlin. Mirror Android's
|
|
13
|
+
/// `coerceIn` behaviour by gating on `isFinite` first.
|
|
14
|
+
internal func effectiveAutoRetries() -> Int {
|
|
15
|
+
guard let raw = self.config.autoRetries, raw.isFinite else { return 1 }
|
|
16
|
+
// Clamp before converting: `Int(_:)` traps on a finite value too large for
|
|
17
|
+
// `Int`, so the bound has to be applied in `Double` space.
|
|
18
|
+
return Int(max(0, min(5, raw)))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Lib-clamped retry backoff in milliseconds. Reads from
|
|
22
|
+
/// `config.retryBackoffMs`, defaults to 500, clamps to [200, 5000].
|
|
23
|
+
/// Floor at 200ms prevents retry storms against misbehaving servers
|
|
24
|
+
/// (e.g. 503 with `Retry-After: 0`). Same `isFinite` gate as
|
|
25
|
+
/// `effectiveAutoRetries`.
|
|
26
|
+
internal func effectiveRetryBackoffMs() -> Int {
|
|
27
|
+
guard let raw = self.config.retryBackoffMs, raw.isFinite else { return 500 }
|
|
28
|
+
return Int(max(200, min(5000, raw)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// MARK: - Progress-emission throttle
|
|
32
|
+
|
|
33
|
+
/// Monotonic clock in milliseconds for the progress-emission throttle.
|
|
34
|
+
/// `systemUptime` is monotonic and needs no extra framework import.
|
|
35
|
+
internal func monotonicNowMs() -> Double {
|
|
36
|
+
ProcessInfo.processInfo.systemUptime * 1000
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Foreground `onProgress` cadence in ms. Reads
|
|
40
|
+
/// `config.progressUpdateIntervalMs`, defaults to 500, floors at the 500 ms
|
|
41
|
+
/// native tick rate, and ignores non-finite / non-positive inputs.
|
|
42
|
+
private func effectiveForegroundProgressIntervalMs() -> Double {
|
|
43
|
+
guard let raw = self.config.progressUpdateIntervalMs, raw.isFinite, raw > 0 else { return 500 }
|
|
44
|
+
return max(500, raw)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Background `onProgress` cadence in ms. Reads
|
|
48
|
+
/// `config.backgroundProgressUpdateIntervalMs`, defaults to the effective
|
|
49
|
+
/// foreground value (⇒ no background reduction), floors at 500, ignores
|
|
50
|
+
/// non-finite / non-positive inputs.
|
|
51
|
+
private func effectiveBackgroundProgressIntervalMs() -> Double {
|
|
52
|
+
let foreground = effectiveForegroundProgressIntervalMs()
|
|
53
|
+
guard let raw = self.config.backgroundProgressUpdateIntervalMs, raw.isFinite, raw > 0 else {
|
|
54
|
+
return foreground
|
|
55
|
+
}
|
|
56
|
+
return max(500, raw)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Push the configured foreground + background cadences into the throttle.
|
|
60
|
+
/// Called on every `configure()`; the gate preserves its runtime state.
|
|
61
|
+
internal func applyProgressEmissionIntervals() {
|
|
62
|
+
self.progressEmissionGate.setIntervals(
|
|
63
|
+
foregroundMs: effectiveForegroundProgressIntervalMs(),
|
|
64
|
+
backgroundMs: effectiveBackgroundProgressIntervalMs(),
|
|
65
|
+
samplerPeriodMs: Self.progressIntervalSeconds * 1000)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Observe app background/foreground so the throttle can switch cadence.
|
|
69
|
+
/// Idempotent — a no-op when already installed. Seeds the current state so a
|
|
70
|
+
/// `configure()` issued while already backgrounded starts throttled.
|
|
71
|
+
internal func installAppLifecycleObservers() {
|
|
72
|
+
guard appLifecycleObservers.isEmpty else { return }
|
|
73
|
+
// `applicationState` is a main-thread API and this runs on `playerQueue`:
|
|
74
|
+
// read it on main and hand the value back. The seed lands a hop later,
|
|
75
|
+
// so the first progress ticks of a configure issued in the background
|
|
76
|
+
// may go out at the foreground cadence.
|
|
77
|
+
DispatchQueue.main.async { [weak self] in
|
|
78
|
+
let backgrounded = UIApplication.shared.applicationState == .background
|
|
79
|
+
self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(backgrounded) }
|
|
80
|
+
}
|
|
81
|
+
let center = NotificationCenter.default
|
|
82
|
+
appLifecycleObservers.append(
|
|
83
|
+
center.addObserver(
|
|
84
|
+
forName: UIApplication.didEnterBackgroundNotification,
|
|
85
|
+
object: nil, queue: .main
|
|
86
|
+
) { [weak self] _ in
|
|
87
|
+
// The gate is player state; the notification arrives on main.
|
|
88
|
+
self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(true) }
|
|
89
|
+
})
|
|
90
|
+
appLifecycleObservers.append(
|
|
91
|
+
center.addObserver(
|
|
92
|
+
forName: UIApplication.willEnterForegroundNotification,
|
|
93
|
+
object: nil, queue: .main
|
|
94
|
+
) { [weak self] _ in
|
|
95
|
+
// The gate is player state; the notification arrives on main.
|
|
96
|
+
self?.playerQueue.async { self?.progressEmissionGate.setBackgrounded(false) }
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Remove the app-lifecycle observers registered by
|
|
101
|
+
/// `installAppLifecycleObservers`. Idempotent.
|
|
102
|
+
internal func removeAppLifecycleObservers() {
|
|
103
|
+
let center = NotificationCenter.default
|
|
104
|
+
appLifecycleObservers.forEach { center.removeObserver($0) }
|
|
105
|
+
appLifecycleObservers.removeAll()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Retry the current item when the network comes back after an outage.
|
|
109
|
+
///
|
|
110
|
+
/// The common real-world case is a tunnel, a lift, or airplane mode: the
|
|
111
|
+
/// retries burn while there is no connection, the item ends up failed, and
|
|
112
|
+
/// nothing tries again — the user has to notice and press play. This runs
|
|
113
|
+
/// recovery at the moment it can actually succeed.
|
|
114
|
+
///
|
|
115
|
+
/// The guards are the whole design. Intent and interruption are checked as
|
|
116
|
+
/// everywhere else, and the retry budget is refunded for **that one track
|
|
117
|
+
/// only** rather than reset wholesale: a flapping connection would otherwise
|
|
118
|
+
/// hand out unlimited retries and defeat the budget entirely.
|
|
119
|
+
internal func handleNetworkRestored() {
|
|
120
|
+
guard self.currentTrackIndex >= 0,
|
|
121
|
+
self.currentTrackIndex < self.queueItemIds.count else { return }
|
|
122
|
+
let qid = self.queueItemIds[self.currentTrackIndex]
|
|
123
|
+
// The unrecovered-failure test is the error key naming *this* track, not
|
|
124
|
+
// the last reported state: by the time a network comes back the announced
|
|
125
|
+
// sequence has settled on `paused`, or on `ended` when the queue drained.
|
|
126
|
+
// The key is cleared by every recovery path, so it is set only while this
|
|
127
|
+
// track is still broken.
|
|
128
|
+
guard NetworkRecoveryPolicy.shouldRetry(
|
|
129
|
+
wantsToPlay: self.wantsToPlay,
|
|
130
|
+
isInterrupted: self.isInterrupted,
|
|
131
|
+
isInError: self.lastErrorQueueItemId == qid,
|
|
132
|
+
isTransient: self.lastErrorCode.map(PlaybackErrorMapping.isTransient) ?? false
|
|
133
|
+
) else { return }
|
|
134
|
+
if (self.retryAttemptsRemaining[qid] ?? 0) == 0 {
|
|
135
|
+
self.retryAttemptsRemaining[qid] = 1
|
|
136
|
+
}
|
|
137
|
+
self.lastErrorQueueItemId = nil
|
|
138
|
+
self.lastErrorCode = nil
|
|
139
|
+
self.retryFailedItem(queueItemId: qid)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Try to recover from a transient failure by reinstalling the engine's
|
|
143
|
+
/// items at the current position. Called from the transient auto-retry
|
|
144
|
+
/// path in `dispatchErrorOrRetry` and from `handleNetworkRestored`.
|
|
145
|
+
///
|
|
146
|
+
/// Implementation: `fullRebuildPlayerQueue(forcingReinstall: true)` with
|
|
147
|
+
/// the current index unchanged. The force is load-bearing: a failed item
|
|
148
|
+
/// keeps its `queueItemId`, so an identity comparison would find the
|
|
149
|
+
/// slice already correct and reinstall nothing. Fresh AVPlayerItems are
|
|
150
|
+
/// built through `AVQueueBuilder.buildPlayerItems` on the existing
|
|
151
|
+
/// `queueItemIds` (so dedup keys remain stable), then playback seeks
|
|
152
|
+
/// back to the captured position.
|
|
153
|
+
///
|
|
154
|
+
/// Gated on the engine, not on `self.player`: the latter is the gapless
|
|
155
|
+
/// queue player and is nil under crossfade.
|
|
156
|
+
internal func retryFailedItem(queueItemId qid: String) {
|
|
157
|
+
// The attempt has arrived, so whatever fails next is a new failure rather
|
|
158
|
+
// than an echo of the one being retried.
|
|
159
|
+
if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
|
|
160
|
+
guard let engine = self.engine else { return }
|
|
161
|
+
// Verify the failed item is still the current one. A subsequent
|
|
162
|
+
// user skip / queue mutation could have moved past it; in that
|
|
163
|
+
// case the retry is moot.
|
|
164
|
+
guard let idx = self.queueItemIds.firstIndex(of: qid),
|
|
165
|
+
idx == self.currentTrackIndex else { return }
|
|
166
|
+
// A retry is an explicit intent to play this track again, so it clears
|
|
167
|
+
// `reachedQueueEnd` exactly as a seek or a skip does. Without this, a
|
|
168
|
+
// queue whose only track failed to load has already drained, been called
|
|
169
|
+
// ended and had its engine parked — so the rebuild below seats an item
|
|
170
|
+
// into a queue that believes it is finished, and playback never resumes.
|
|
171
|
+
self.reachedQueueEnd = false
|
|
172
|
+
// Captured before the rebuild, which reloads the item from zero: a
|
|
173
|
+
// transient blip well into a long track must not restart it.
|
|
174
|
+
let resumePosition = engine.currentPositionSeconds
|
|
175
|
+
// Forced: this exists to replace an item that failed, and a failed item
|
|
176
|
+
// keeps its `queueItemId`, so an identity comparison would find the slice
|
|
177
|
+
// already correct and rebuild nothing.
|
|
178
|
+
self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
|
|
179
|
+
// No seek beats a seek to the wrong track: with a trail installed,
|
|
180
|
+
// engine-index 0 is a track already played rather than the failed one.
|
|
181
|
+
if resumePosition.isFinite, resumePosition > 0,
|
|
182
|
+
let engineIdx = self.engineIndex(forQueuePosition: idx) {
|
|
183
|
+
engine.seek(toIndex: engineIdx, position: resumePosition)
|
|
184
|
+
}
|
|
185
|
+
// Resume only what was actually running. A transient load failure can
|
|
186
|
+
// arrive while the user is paused, and the rebuild leaves the item seated
|
|
187
|
+
// at rate 0 either way, so the transport intent is what separates
|
|
188
|
+
// "recover playback" from "start playing something nobody asked for".
|
|
189
|
+
// Through the engine so the user's playback speed is restored (a raw
|
|
190
|
+
// AVPlayer.play() resets rate to 1.0).
|
|
191
|
+
// An auto-retry completing mid-interruption must not start audio.
|
|
192
|
+
if self.wantsToPlay, !self.isInterrupted, engine.timeControlStatus != .playing {
|
|
193
|
+
self.beginPlayback()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
extension TrackPlayer {
|
|
4
|
+
// MARK: - Skip
|
|
5
|
+
//
|
|
6
|
+
// AVQueuePlayer is forward-only, so skipToPrevious and skipToIndex
|
|
7
|
+
// (when target < current) both require rebuilding the player's
|
|
8
|
+
// queue from the authoritative `self.tracks` list at the new
|
|
9
|
+
// index. skipToNext is a surgical `player.advanceToNextItem()`
|
|
10
|
+
// when the next track is already queued; wraps round (via full
|
|
11
|
+
// rebuild) when `repeatModeState == .queue` and we're past the
|
|
12
|
+
// end.
|
|
13
|
+
//
|
|
14
|
+
// Shuffle: destructive action `shuffleQueue()`. Mutates
|
|
15
|
+
// `self.tracks` to a Fisher-Yates permutation, resets
|
|
16
|
+
// `currentTrackIndex` to 0, resumes playback if was playing, and
|
|
17
|
+
// returns the new ordering to the consumer. Skip arithmetic walks
|
|
18
|
+
// linearly across whatever ordering the consumer last installed;
|
|
19
|
+
// there is no shadow shuffle queue + no shuffle-aware skip
|
|
20
|
+
// overloads.
|
|
21
|
+
|
|
22
|
+
/// `skipToIndex` is an explicit consumer intent — it always honours
|
|
23
|
+
/// the target index in every repeat mode, including `.queue` (it
|
|
24
|
+
/// never wraps — the target is absolute). Out-of-range indices
|
|
25
|
+
/// silently no-op.
|
|
26
|
+
func skipToIndex(index: Double) throws -> Promise<Void> {
|
|
27
|
+
return enqueue {
|
|
28
|
+
// Engine-aware: under CrossfadeEngine `self.player` is nil
|
|
29
|
+
// by design. Gating the skip on the engine surface lets
|
|
30
|
+
// mid-play engine swaps + crossfade-mode user skips work.
|
|
31
|
+
guard self.engine != nil else { return }
|
|
32
|
+
// See `InputGuards.validQueueIndex` for the rationale —
|
|
33
|
+
// validates non-finite + out-of-range in Double space so
|
|
34
|
+
// the subsequent Int conversion is bounded.
|
|
35
|
+
guard let target = InputGuards.validQueueIndex(
|
|
36
|
+
index, count: self.tracks.count
|
|
37
|
+
) else { return }
|
|
38
|
+
// During cast, jump the receiver to the target index; its status
|
|
39
|
+
// echo drives the metadata + active-track update through the
|
|
40
|
+
// bridge (same as skipToNext/Previous). The local engine stays
|
|
41
|
+
// paused, so a local index write would desync from the receiver.
|
|
42
|
+
if CastTransportRouter.routeSkipToIndex(index: target) { return }
|
|
43
|
+
self.applyNewCurrentIndex(
|
|
44
|
+
newIndex: target, reason: .userSkipToIndex)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func skipToNext() throws -> Promise<Void> {
|
|
49
|
+
return enqueue {
|
|
50
|
+
if CastTransportRouter.routeSkipToNext() { return }
|
|
51
|
+
guard self.engine != nil else { return }
|
|
52
|
+
guard let next = self.computeNextIndex() else { return }
|
|
53
|
+
self.applyNewCurrentIndex(
|
|
54
|
+
newIndex: next, reason: .userSkipNext)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Skip-previous, resolved by `QueueSkipArithmetic.resolveSkipToPrevious`
|
|
59
|
+
/// from `PlayerConfig.skipToPreviousBehavior`. With the default `.previous`
|
|
60
|
+
/// this goes to the previous track if there is one (`.off`/`.track` don't
|
|
61
|
+
/// wrap, `.queue` wraps; `.track` navigates like `.off` and repeat-one
|
|
62
|
+
/// persists). With `.restartOrPrevious` it restarts the current track (seek
|
|
63
|
+
/// to 0) when past the threshold, otherwise goes to the previous track.
|
|
64
|
+
func skipToPrevious() throws -> Promise<Void> {
|
|
65
|
+
return enqueue {
|
|
66
|
+
if CastTransportRouter.routeSkipToPrevious() { return }
|
|
67
|
+
guard let engine = self.engine else { return }
|
|
68
|
+
let positionMs = Int(max(0, engine.currentPositionSeconds) * 1000)
|
|
69
|
+
let action = QueueSkipArithmetic.resolveSkipToPrevious(
|
|
70
|
+
trackCount: self.tracks.count,
|
|
71
|
+
currentIndex: self.currentTrackIndex,
|
|
72
|
+
repeatMode: self.repeatModeAsSkipMode(),
|
|
73
|
+
restartEnabled: self.skipRestartEnabled(),
|
|
74
|
+
positionMs: positionMs)
|
|
75
|
+
switch action {
|
|
76
|
+
case .previous(let idx):
|
|
77
|
+
self.applyNewCurrentIndex(newIndex: idx, reason: .userSkipPrevious)
|
|
78
|
+
case .restartCurrent:
|
|
79
|
+
// Restart the current track from 0 — a seek, not a track change.
|
|
80
|
+
// Same-index `applyNewCurrentIndex` takes its restart branch
|
|
81
|
+
// (currentItem unchanged → no `onTrackChange`), engine-aware across
|
|
82
|
+
// gapless (live / drained-rebuild) + crossfade.
|
|
83
|
+
self.applyNewCurrentIndex(
|
|
84
|
+
newIndex: self.currentTrackIndex, reason: .userSkipPrevious)
|
|
85
|
+
case .noOp:
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Next-track index. Delegates to `QueueSkipArithmetic.computeNext`.
|
|
92
|
+
/// cur<0 sentinel: "nothing playing yet" → skipToNext is a no-op
|
|
93
|
+
/// (consumer starts via `setQueue` + `play` or `skipToIndex(0)`).
|
|
94
|
+
internal func computeNextIndex() -> Int? {
|
|
95
|
+
return QueueSkipArithmetic.computeNext(
|
|
96
|
+
trackCount: self.tracks.count,
|
|
97
|
+
currentIndex: self.currentTrackIndex,
|
|
98
|
+
repeatMode: repeatModeAsSkipMode())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Previous-track index. Delegates to `QueueSkipArithmetic.computePrevious`.
|
|
102
|
+
/// Same cur<0 early-bail as `computeNextIndex`.
|
|
103
|
+
internal func computePreviousIndex() -> Int? {
|
|
104
|
+
return QueueSkipArithmetic.computePrevious(
|
|
105
|
+
trackCount: self.tracks.count,
|
|
106
|
+
currentIndex: self.currentTrackIndex,
|
|
107
|
+
repeatMode: repeatModeAsSkipMode())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Whether skip-to-previous restarts the current track past the threshold
|
|
111
|
+
/// (`PlayerConfig.skipToPreviousBehavior == .restartOrPrevious`). Read live
|
|
112
|
+
/// from the current config at each skip + capability recompute.
|
|
113
|
+
private func skipRestartEnabled() -> Bool {
|
|
114
|
+
return self.config.skipToPreviousBehavior == .restartOrPrevious
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Map the Nitrogen-generated `RepeatMode` enum onto the pure-Swift
|
|
118
|
+
/// `QueueSkipRepeatMode` so `QueueSkipArithmetic` can stay off the
|
|
119
|
+
/// Swift ↔ C++ interop path.
|
|
120
|
+
internal func repeatModeAsSkipMode() -> QueueSkipRepeatMode {
|
|
121
|
+
switch self.repeatModeState {
|
|
122
|
+
case .off: return .off
|
|
123
|
+
case .track: return .track
|
|
124
|
+
case .queue: return .queue
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Recompute `cachedSkipCapability` from
|
|
129
|
+
/// authoritative state (`tracks`, `currentTrackIndex`,
|
|
130
|
+
/// `repeatModeState`) and fire `onSkipCapabilityChange` if either
|
|
131
|
+
/// flipped.
|
|
132
|
+
///
|
|
133
|
+
/// **Threading invariant:** must be called on `playerQueue`. Reads
|
|
134
|
+
/// `tracks` / `currentTrackIndex` / `repeatModeState`, all of
|
|
135
|
+
/// which mutate only on that queue, so the read is race-free; the
|
|
136
|
+
/// listener fire iterates a snapshot under `ListenerRegistry`'s
|
|
137
|
+
/// internal lock so subscriber add/remove during the fire round
|
|
138
|
+
/// is safe.
|
|
139
|
+
///
|
|
140
|
+
/// **Dedup:** the listener fires only when the (prev, next) tuple
|
|
141
|
+
/// actually changes. Two back-to-back recompute calls that land on
|
|
142
|
+
/// the same tuple emit once, not twice. This is what makes it safe
|
|
143
|
+
/// to scatter `recomputeCapabilities()` calls across every
|
|
144
|
+
/// mutation site without producing chatty events.
|
|
145
|
+
///
|
|
146
|
+
/// **Callers** (every site that writes `tracks` /
|
|
147
|
+
/// `currentTrackIndex` / `repeatModeState`):
|
|
148
|
+
/// - `setQueue`, `addToQueueBody`, `removeFromQueueBody`,
|
|
149
|
+
/// `moveInQueueBody`, `clearQueue`
|
|
150
|
+
/// - `applyNewCurrentIndex` (covers skipToNext / skipToPrevious /
|
|
151
|
+
/// skipToIndex)
|
|
152
|
+
/// - `handleCurrentItemDidChange` (covers AVQueuePlayer auto-advance
|
|
153
|
+
/// that updates `currentTrackIndex` via `matchTrackIndex`)
|
|
154
|
+
/// - `setRepeatMode`, `shuffleQueue`
|
|
155
|
+
/// - `destroy` (resets to (false, false) so a final emit lets
|
|
156
|
+
/// subscribers know the player is gone)
|
|
157
|
+
internal func recomputeCapabilities() {
|
|
158
|
+
// Building the mirror maps the whole track list, so it is only built when
|
|
159
|
+
// a car is attached to receive it. The flag is read once and travels with
|
|
160
|
+
// the snapshot: a `nil` built because no car was attached must never be
|
|
161
|
+
// mistaken downstream for a `nil` that means "clear the mirror".
|
|
162
|
+
let carAttached = CarPlayCoordinator.shared.isCarConnected
|
|
163
|
+
let queueSnapshot = carAttached ? buildCarPlayQueueSnapshot() : nil
|
|
164
|
+
publishCapabilities(
|
|
165
|
+
computeCapabilities(), queueSnapshot: queueSnapshot, carAttached: carAttached)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Skip capability derived from `tracks` / `currentTrackIndex` /
|
|
169
|
+
/// `repeatModeState`. Pure — reads state and returns a value, no
|
|
170
|
+
/// listener fire and no remote-command or CarPlay write.
|
|
171
|
+
internal func computeCapabilities() -> SkipCapability {
|
|
172
|
+
let mode = repeatModeAsSkipMode()
|
|
173
|
+
let count = tracks.count
|
|
174
|
+
let cur = currentTrackIndex
|
|
175
|
+
return SkipCapability(
|
|
176
|
+
canSkipPrevious: QueueSkipArithmetic.canSkipPrevious(
|
|
177
|
+
trackCount: count, currentIndex: cur, repeatMode: mode,
|
|
178
|
+
restartEnabled: skipRestartEnabled()),
|
|
179
|
+
canSkipNext: QueueSkipArithmetic.canSkipNext(
|
|
180
|
+
trackCount: count, currentIndex: cur, repeatMode: mode))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Publish `capability` and `queueSnapshot`: refresh the CarPlay Up Next
|
|
184
|
+
/// mirror, then cache the pair, fire `onSkipCapabilityChange` and update the
|
|
185
|
+
/// remote-command centre when it actually changed. Runs on `playerQueue`;
|
|
186
|
+
/// `RemoteCommands` hands its own work to main, which owns
|
|
187
|
+
/// `MPRemoteCommandCenter`.
|
|
188
|
+
internal func publishCapabilities(
|
|
189
|
+
_ capability: SkipCapability, queueSnapshot: CarPlayQueueSnapshot?,
|
|
190
|
+
carAttached: Bool
|
|
191
|
+
) {
|
|
192
|
+
// Every queue / current-index / repeat-mode change funnels through here,
|
|
193
|
+
// so it's the single point that keeps the CarPlay Up Next mirror current
|
|
194
|
+
// — done before the skip-capability dedup guard below so reorder/metadata
|
|
195
|
+
// changes (which don't flip skip capability) still refresh the mirror.
|
|
196
|
+
syncCarPlayQueueMirror(queueSnapshot, carAttached: carAttached)
|
|
197
|
+
let current = cachedSkipCapability
|
|
198
|
+
guard capability.canSkipPrevious != current.canSkipPrevious
|
|
199
|
+
|| capability.canSkipNext != current.canSkipNext else {
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
cachedSkipCapability = capability
|
|
203
|
+
skipCapabilityListeners.forEach { $0(capability) }
|
|
204
|
+
self.remoteCommands.setSkipCapability(
|
|
205
|
+
canSkipNext: capability.canSkipNext,
|
|
206
|
+
canSkipPrevious: capability.canSkipPrevious
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// Mirror `snapshot` into the process-wide [CarPlayQueueProvider] so the
|
|
211
|
+
/// CarPlay Up Next surface renders the current playback queue without
|
|
212
|
+
/// round-tripping through JS; `nil` clears the mirror so the Up Next button
|
|
213
|
+
/// hides. `carAttached` is the caller's single read of the connectivity
|
|
214
|
+
/// flag, so the decision to build a snapshot and the decision to write one
|
|
215
|
+
/// cannot disagree. Driven from `publishCapabilities`, which every queue /
|
|
216
|
+
/// current-index / repeat-mode change funnels through, and from
|
|
217
|
+
/// `CarPlayCoordinator.onCarAttached`.
|
|
218
|
+
internal func syncCarPlayQueueMirror(
|
|
219
|
+
_ snapshot: CarPlayQueueSnapshot?, carAttached: Bool
|
|
220
|
+
) {
|
|
221
|
+
// With no car attached there is nothing to receive the update, and the
|
|
222
|
+
// browse surface rebuilds from live state when one connects.
|
|
223
|
+
guard carAttached else { return }
|
|
224
|
+
// `CarPlayQueueProvider.setSnapshot` guards its store with a lock and
|
|
225
|
+
// posts its update notification on the main queue itself.
|
|
226
|
+
CarPlayCoordinator.shared.queueProvider.setSnapshot(snapshot)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Queue mirror for the CarPlay Up Next list, or `nil` for an empty queue
|
|
230
|
+
/// (which clears the mirror and hides the button). Pure — reads `tracks`
|
|
231
|
+
/// and `currentTrackIndex` and returns a value.
|
|
232
|
+
internal func buildCarPlayQueueSnapshot() -> CarPlayQueueSnapshot? {
|
|
233
|
+
guard !self.tracks.isEmpty else { return nil }
|
|
234
|
+
let entries = self.tracks.map { track in
|
|
235
|
+
CarPlayQueueEntry(
|
|
236
|
+
title: track.title ?? "",
|
|
237
|
+
subtitle: track.artist ?? track.album,
|
|
238
|
+
artworkUrl: track.artworkUrl
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
return CarPlayQueueSnapshot(
|
|
242
|
+
entries: entries, currentIndex: self.currentTrackIndex)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Apply a new `currentTrackIndex` and sync the AVQueuePlayer.
|
|
246
|
+
///
|
|
247
|
+
/// **Forward skip** (newIndex > oldIndex) — surgical trim of items
|
|
248
|
+
/// between current and target + `player.advanceToNextItem()`. Safe
|
|
249
|
+
/// because `preferredForwardBufferDuration = 0.0` closes the
|
|
250
|
+
/// chain-advance-while-paused vector and the `didPlayToEndPending`
|
|
251
|
+
/// resync gate prevents any chain-advance from corrupting
|
|
252
|
+
/// `currentTrackIndex` even if it did fire.
|
|
253
|
+
///
|
|
254
|
+
/// **Backward skip** (newIndex < oldIndex, including .queue-mode
|
|
255
|
+
/// wraparound) — full rebuild via `fullRebuildPlayerQueue`.
|
|
256
|
+
/// AVQueuePlayer is forward-only; backward navigation always
|
|
257
|
+
/// requires removing all items + re-inserting from the new index.
|
|
258
|
+
///
|
|
259
|
+
/// - `newIndex == oldIndex` → restarts the current track from position 0
|
|
260
|
+
/// with no track-change event — seeking in place, or reinstalling the
|
|
261
|
+
/// track when it is not materialised in the engine.
|
|
262
|
+
/// - out-of-range `newIndex` → silent no-op.
|
|
263
|
+
///
|
|
264
|
+
/// **Void return is intentional.** Silent-return cases here
|
|
265
|
+
/// (out-of-range, identity skip) are legitimate; track-change /
|
|
266
|
+
/// queue-end events flow through `handleCurrentItemDidChange`
|
|
267
|
+
/// driven by the AVQueuePlayer `\.currentItem` KVO, which is the
|
|
268
|
+
/// source of truth for whether a track actually became current.
|
|
269
|
+
/// A `Bool` return would be a *prediction* of whether KVO will
|
|
270
|
+
/// fire — predictions drift, KVO doesn't.
|
|
271
|
+
internal func applyNewCurrentIndex(
|
|
272
|
+
newIndex: Int, reason: TrackChangeReason
|
|
273
|
+
) {
|
|
274
|
+
guard newIndex >= 0, newIndex < self.tracks.count else { return }
|
|
275
|
+
|
|
276
|
+
let oldIndex = self.currentTrackIndex
|
|
277
|
+
if newIndex == oldIndex {
|
|
278
|
+
// A same-index skip is an explicit "restart this track" — no longer at
|
|
279
|
+
// the end of the queue. Clear `reachedQueueEnd` here because this branch returns
|
|
280
|
+
// before `dispatchTrackChange` (which is the other clear point), so a
|
|
281
|
+
// crossfade in-place replay after end-of-queue would otherwise stay
|
|
282
|
+
// stuck on `.ended`.
|
|
283
|
+
self.reachedQueueEnd = false
|
|
284
|
+
// Same-index user skip = "restart this track from 0". Don't
|
|
285
|
+
// rebuild the queue or fire onTrackChange (the active track
|
|
286
|
+
// hasn't changed), but do reset the engine's playhead so the
|
|
287
|
+
// call-site contract is "track N from position 0" regardless
|
|
288
|
+
// of where the engine currently is — including after a
|
|
289
|
+
// setPlaybackMode swap that carried position across.
|
|
290
|
+
//
|
|
291
|
+
// Engine-aware: an engine indexes within the slice it was handed,
|
|
292
|
+
// never in the lib's queue-position space, so a queue position has
|
|
293
|
+
// to be translated by item identity before it can be seeked. For
|
|
294
|
+
// gapless the reset goes through the AVQueuePlayer's currentItem
|
|
295
|
+
// directly instead.
|
|
296
|
+
//
|
|
297
|
+
// A drained gapless AVQueuePlayer (`currentItem == nil`, so `items()` is
|
|
298
|
+
// empty) can't be seeked — `actionAtItemEnd = .advance` consumes items as
|
|
299
|
+
// they finish, so at end-of-queue there's nothing to seek. Rebuild at the
|
|
300
|
+
// current index to reload the track so the restart intent isn't dropped.
|
|
301
|
+
// Crossfade (`self.player == nil`) keeps its item on the leading leg, so
|
|
302
|
+
// the engine seek resets it in place.
|
|
303
|
+
if let player = self.player, player.currentItem != nil {
|
|
304
|
+
player.currentItem?.seek(to: .zero, completionHandler: nil)
|
|
305
|
+
} else if self.player != nil {
|
|
306
|
+
self.performingMutation { self.fullRebuildPlayerQueue() }
|
|
307
|
+
} else if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
|
|
308
|
+
self.engine?.seek(toIndex: engineIdx, position: 0)
|
|
309
|
+
} else {
|
|
310
|
+
// Target isn't materialised in the engine — reinstall at it.
|
|
311
|
+
self.performingMutation { self.fullRebuildPlayerQueue() }
|
|
312
|
+
}
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Stash reason only now that we know a real transition is
|
|
317
|
+
// happening. Consumed by `handleCurrentItemDidChange` on the
|
|
318
|
+
// next non-nil `\.currentItem` KVO fire. Mid-rebuild nil fires
|
|
319
|
+
// are suppressed by `isMutatingPlayerQueue` so the reason
|
|
320
|
+
// survives until the terminal currentItem.
|
|
321
|
+
self.pendingTrackChangeReason = reason
|
|
322
|
+
// Moving to another position is an intent to play it, so the queue is
|
|
323
|
+
// not over — cleared here rather than only on the track change that
|
|
324
|
+
// follows, so a target that cannot be built still reaches a verdict.
|
|
325
|
+
self.reachedQueueEnd = false
|
|
326
|
+
|
|
327
|
+
self.performingMutation {
|
|
328
|
+
if oldIndex < 0 {
|
|
329
|
+
// Queue was empty / uninitialised — populate player fresh.
|
|
330
|
+
self.currentTrackIndex = newIndex
|
|
331
|
+
fullRebuildPlayerQueue()
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
self.currentTrackIndex = newIndex
|
|
336
|
+
|
|
337
|
+
if self.player == nil {
|
|
338
|
+
// CrossfadeEngine seeks by index within its own queue, so translate
|
|
339
|
+
// the queue position by item identity first. Not found means the
|
|
340
|
+
// target isn't materialised — reinstall the queue at it rather than
|
|
341
|
+
// issuing a seek the engine would reject. The gapless forward-trim
|
|
342
|
+
// (`forwardSkipBy`) is not usable here: it assumes `allMediaItems`
|
|
343
|
+
// is the remaining forward-only slice, which is false for crossfade.
|
|
344
|
+
if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
|
|
345
|
+
self.engine?.seek(toIndex: engineIdx, position: 0)
|
|
346
|
+
// The seek's own track-change fires while this mutation is in
|
|
347
|
+
// flight, and the delegate skips the top-up for the duration; a
|
|
348
|
+
// skip onto the last installed position would otherwise leave no
|
|
349
|
+
// run ahead of it until the next transition, which a hard cut is.
|
|
350
|
+
self.topUpWindow()
|
|
351
|
+
} else {
|
|
352
|
+
// The target is not in the engine's run — a track appended after the
|
|
353
|
+
// queue ended, say. The rebuild installs it, but its surgical path
|
|
354
|
+
// extends the run behind the current item without re-seating the
|
|
355
|
+
// engine, so `currentTrackIndex` would advance while the engine kept
|
|
356
|
+
// playing the old item and no `onTrackChange` fired. Seek to the
|
|
357
|
+
// target once the run holds it so the skip actually moves the engine
|
|
358
|
+
// and the transition is announced.
|
|
359
|
+
fullRebuildPlayerQueue()
|
|
360
|
+
if let engineIdx = self.engineIndex(forQueuePosition: newIndex) {
|
|
361
|
+
self.engine?.seek(toIndex: engineIdx, position: 0)
|
|
362
|
+
self.topUpWindow()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else if newIndex > oldIndex {
|
|
366
|
+
forwardSkipBy(delta: newIndex - oldIndex)
|
|
367
|
+
} else {
|
|
368
|
+
// Backward skip / `.queue`-mode wraparound — full rebuild.
|
|
369
|
+
// AVQueuePlayer is forward-only; backward navigation has no
|
|
370
|
+
// surgical alternative.
|
|
371
|
+
fullRebuildPlayerQueue()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Every skip shifts the lookahead window. Reschedule covers all
|
|
375
|
+
// three skip methods (skipToIndex / Next / Previous all funnel
|
|
376
|
+
// through here).
|
|
377
|
+
self.rescheduleLookahead()
|
|
378
|
+
self.recomputeCapabilities()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Forward skip via surgical-trim + advanceToNextItem on the
|
|
382
|
+
/// player's existing items array. Caller holds the surrounding
|
|
383
|
+
/// `performingMutation` scope; this helper does NOT re-enter it.
|
|
384
|
+
/// Falls back to `fullRebuildPlayerQueue` when the player snapshot
|
|
385
|
+
/// is shorter than `delta` (defensive against partial chain-
|
|
386
|
+
/// advance / error-recovery state).
|
|
387
|
+
///
|
|
388
|
+
/// Workaround: `advanceToNextItem` while paused can chain-advance
|
|
389
|
+
/// through unbuffered items because AVQueuePlayer interprets
|
|
390
|
+
/// unresolved durations as "played to end". Mitigated by
|
|
391
|
+
/// `preferredForwardBufferDuration = 0.0` at item construction +
|
|
392
|
+
/// the `didPlayToEndPending` resync gate.
|
|
393
|
+
/// TODO: monitor upstream for fixes — https://developer.apple.com/forums/thread/683744
|
|
394
|
+
private func forwardSkipBy(delta: Int) {
|
|
395
|
+
guard let engine = self.engine else { return }
|
|
396
|
+
let snapshot = engine.allMediaItems
|
|
397
|
+
guard snapshot.count > delta else {
|
|
398
|
+
fullRebuildPlayerQueue()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
// Rearm the initial-buffer leniency window for the post-advance
|
|
402
|
+
// currentItem. `fullRebuildPlayerQueue` does this before
|
|
403
|
+
// inserting; surgical-trim doesn't touch items-array order, so
|
|
404
|
+
// the call is explicit here.
|
|
405
|
+
rearmGaplessFlip()
|
|
406
|
+
let trimRange = 1 ..< delta
|
|
407
|
+
let toRemove = Array(snapshot[trimRange])
|
|
408
|
+
for item in toRemove {
|
|
409
|
+
engine.remove(item)
|
|
410
|
+
}
|
|
411
|
+
engine.skipToNext()
|
|
412
|
+
// The trim consumed the run ahead of the playhead. A skip while paused
|
|
413
|
+
// never ticks the progress observer, so nothing else would refill it.
|
|
414
|
+
self.topUpWindow()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/// Reschedule the proactive prefetcher after a queue or skip
|
|
418
|
+
/// mutation. Reads `tracks` + `currentTrackIndex` and dispatches
|
|
419
|
+
/// the next N remote tracks to [LookaheadCachePrefetcher]. No-op
|
|
420
|
+
/// when the prefetcher hasn't been built (configure not yet
|
|
421
|
+
/// called).
|
|
422
|
+
internal func rescheduleLookahead() {
|
|
423
|
+
self.refreshCacheProtection()
|
|
424
|
+
guard let prefetcher = self.lookaheadCachePrefetcher else { return }
|
|
425
|
+
prefetcher.reschedule(
|
|
426
|
+
tracks: self.tracks,
|
|
427
|
+
currentIndex: self.currentTrackIndex
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/// Tell the cache which files eviction must not take: the playing track,
|
|
432
|
+
/// the tracks being prefetched ahead of it, and one behind for an
|
|
433
|
+
/// immediate skip-back.
|
|
434
|
+
///
|
|
435
|
+
/// `commitDownloadedFile` evicts after every prefetch commit, so without
|
|
436
|
+
/// this the entry backing the playing item can be deleted while it plays.
|
|
437
|
+
/// Bounded deliberately — protecting the whole remaining queue would leave
|
|
438
|
+
/// nothing evictable and turn each commit into a cache wipe.
|
|
439
|
+
private func refreshCacheProtection() {
|
|
440
|
+
guard let cache = self.lookaheadCache else { return }
|
|
441
|
+
let cur = self.currentTrackIndex
|
|
442
|
+
guard cur >= 0, cur < self.tracks.count else {
|
|
443
|
+
cache.setProtectedUrls([])
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
// The count crosses from JS as a Double, so Infinity and NaN are reachable
|
|
447
|
+
// and `Int(_:)` traps on both. Clamp before converting.
|
|
448
|
+
let ahead = min(
|
|
449
|
+
InputGuards.validLookaheadCount(self.lookaheadConfig.lookaheadCount),
|
|
450
|
+
self.tracks.count)
|
|
451
|
+
let lower = max(0, cur - 1)
|
|
452
|
+
let upper = min(self.tracks.count - 1, cur + ahead)
|
|
453
|
+
cache.setProtectedUrls(Set(self.tracks[lower ... upper].map { $0.url }))
|
|
454
|
+
}
|
|
455
|
+
}
|