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
package/ios/LookaheadCache.swift
CHANGED
|
@@ -86,6 +86,9 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
86
86
|
/// scheduling immediately. Guarded by `queue`. The URLSession is
|
|
87
87
|
/// only invalidated by `tearDown()` at full destroy.
|
|
88
88
|
private var isEnabled: Bool = true
|
|
89
|
+
/// Downloads admitted and not yet finished; `tearDown` waits on it before
|
|
90
|
+
/// invalidating the session.
|
|
91
|
+
private let inFlightDownloads = DispatchGroup()
|
|
89
92
|
|
|
90
93
|
// MARK: - Init / lifecycle
|
|
91
94
|
|
|
@@ -115,7 +118,8 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
115
118
|
internal convenience init(
|
|
116
119
|
directory: URL = LookaheadCache.defaultDirectory(),
|
|
117
120
|
maxSizeBytes: UInt64 = LookaheadCache.DEFAULT_MAX_SIZE_BYTES,
|
|
118
|
-
evictionPolicy: EvictionPolicy = .lru
|
|
121
|
+
evictionPolicy: EvictionPolicy = .lru,
|
|
122
|
+
allowsCellularAccess: Bool = true
|
|
119
123
|
) {
|
|
120
124
|
// `.ephemeral` rather than `.default` so the OS shared URL
|
|
121
125
|
// response cache doesn't double-cache our prefetch bytes (we
|
|
@@ -136,10 +140,25 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
136
140
|
directory: directory,
|
|
137
141
|
maxSizeBytes: maxSizeBytes,
|
|
138
142
|
evictionPolicy: evictionPolicy,
|
|
139
|
-
urlSession: URLSession(configuration: cfg)
|
|
143
|
+
urlSession: URLSession(configuration: cfg),
|
|
144
|
+
allowsCellularAccess: allowsCellularAccess
|
|
140
145
|
)
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
/// Whether prefetch downloads may use cellular. Set on each request rather
|
|
149
|
+
/// than baked into the session, so a change applies from the next request
|
|
150
|
+
/// without disturbing anything in flight — that one keeps the value it was
|
|
151
|
+
/// issued with. `URLRequest.allowsCellularAccess = false` is an
|
|
152
|
+
/// unconditional refusal, where the session's own value is only a
|
|
153
|
+
/// permissive default.
|
|
154
|
+
internal var allowsCellularAccess: Bool {
|
|
155
|
+
get { queue.sync { cellularAllowed } }
|
|
156
|
+
set { queue.sync { cellularAllowed = newValue } }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Guarded by `queue`, like the rest of the mutable cache state.
|
|
160
|
+
private var cellularAllowed: Bool = true
|
|
161
|
+
|
|
143
162
|
/// Test-only init — tests inject a `URLSession` wired to a
|
|
144
163
|
/// `URLProtocol` stub here. `internal` (not `@_spi public`)
|
|
145
164
|
/// because `public` symbols still emit C++ interop bindings even
|
|
@@ -150,12 +169,14 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
150
169
|
directory: URL,
|
|
151
170
|
maxSizeBytes: UInt64,
|
|
152
171
|
evictionPolicy: EvictionPolicy = .lru,
|
|
153
|
-
urlSession: URLSession
|
|
172
|
+
urlSession: URLSession,
|
|
173
|
+
allowsCellularAccess: Bool = true
|
|
154
174
|
) {
|
|
155
175
|
self.cacheDirectory = directory
|
|
156
176
|
self.maxBytes = maxSizeBytes
|
|
157
177
|
self.evictionPolicy = evictionPolicy
|
|
158
178
|
self.urlSession = urlSession
|
|
179
|
+
self.cellularAllowed = allowsCellularAccess
|
|
159
180
|
Self.ensureDirectoryExists(at: directory)
|
|
160
181
|
self.index = Self.loadOrCreateIndex(at: directory)
|
|
161
182
|
}
|
|
@@ -165,12 +186,30 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
165
186
|
/// Must be called from `TrackPlayer.destroy` BEFORE any consumer
|
|
166
187
|
/// drops their reference to the cache instance.
|
|
167
188
|
///
|
|
189
|
+
/// Clears `isEnabled` before invalidating, so a prefetcher Task
|
|
190
|
+
/// that reaches `download` after the teardown returns at the
|
|
191
|
+
/// quiescence gate instead of asking an invalidated session for a
|
|
192
|
+
/// task — which raises the Obj-C exception `Task created in a
|
|
193
|
+
/// session that has been invalidated`, uncatchable in Swift's
|
|
194
|
+
/// `do-catch`. Unlike `disable()` the session is not reusable
|
|
195
|
+
/// afterwards; a teardown ends the instance's life.
|
|
196
|
+
///
|
|
168
197
|
/// Named `tearDown` (not `release`) to avoid the Obj-C
|
|
169
198
|
/// `NSObject.release` selector collision; pure Swift class so
|
|
170
199
|
/// there is no actual NSObject to inherit, but staying clear
|
|
171
200
|
/// of the name keeps things friendly if a future @objc bridge
|
|
172
201
|
/// gets added.
|
|
173
202
|
public func tearDown() {
|
|
203
|
+
queue.sync {
|
|
204
|
+
self.isEnabled = false
|
|
205
|
+
self.pendingDownloads.removeAll()
|
|
206
|
+
}
|
|
207
|
+
// A download admitted before the flag flipped may not have reached the
|
|
208
|
+
// session yet; asking an invalidated session for a task raises an
|
|
209
|
+
// Objective-C exception Swift cannot catch. The prefetcher is cancelled
|
|
210
|
+
// before this is called, so admitted downloads leave the group as their
|
|
211
|
+
// cancellation lands — the wait is a bound, not a normal cost.
|
|
212
|
+
_ = inFlightDownloads.wait(timeout: .now() + .seconds(2))
|
|
174
213
|
urlSession.invalidateAndCancel()
|
|
175
214
|
}
|
|
176
215
|
|
|
@@ -182,7 +221,8 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
182
221
|
/// `Task created in a session that has been invalidated`,
|
|
183
222
|
/// uncatchable in Swift's `do-catch`).
|
|
184
223
|
///
|
|
185
|
-
/// Effects: clears the `isEnabled` flag so `download` no-ops
|
|
224
|
+
/// Effects: clears the `isEnabled` flag so `download` no-ops, `cachedURL`
|
|
225
|
+
/// answers nil (the read-through server then serves from the origin) + the
|
|
186
226
|
/// in-flight dedup set so a re-enable starts clean, and cancels
|
|
187
227
|
/// any URLSession tasks already mid-flight via `getAllTasks`. The
|
|
188
228
|
/// session itself stays alive — a later `enable()` reuses it.
|
|
@@ -205,19 +245,38 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
247
|
|
|
248
|
+
/// URLs that must survive eviction: the playing track and the window
|
|
249
|
+
/// around it. Every eviction path consults this, not just a shrink —
|
|
250
|
+
/// `commitDownloadedFile` evicts after each prefetch commit, so without a
|
|
251
|
+
/// live set the entry backing the playing item can be deleted underneath
|
|
252
|
+
/// it and playback stops with a file that no longer exists.
|
|
253
|
+
///
|
|
254
|
+
/// Written by `TrackPlayer` on track change and on prefetch reschedule.
|
|
255
|
+
/// Bounded by the caller: protecting the whole remaining queue would leave
|
|
256
|
+
/// nothing evictable and turn every commit into a full cache wipe.
|
|
257
|
+
private var liveProtectedUrls: Set<String> = []
|
|
258
|
+
|
|
259
|
+
/// Replace the set of URLs eviction must not touch.
|
|
260
|
+
///
|
|
261
|
+
/// Asynchronous by design. The caller is `rescheduleLookahead`, which runs on
|
|
262
|
+
/// the queue serialising player state from every queue and skip path, and the
|
|
263
|
+
/// cache queue also serves download commits — a synchronous hop would park
|
|
264
|
+
/// that queue behind disk work on a hot path. The set is advisory for the next eviction, and
|
|
265
|
+
/// evictions run on this same serial queue, so a write enqueued here is
|
|
266
|
+
/// visible to every eviction that follows it.
|
|
267
|
+
public func setProtectedUrls(_ urls: Set<String>) {
|
|
268
|
+
queue.async { self.liveProtectedUrls = urls }
|
|
269
|
+
}
|
|
270
|
+
|
|
208
271
|
/// Update the cache budget at runtime. On shrink, runs
|
|
209
|
-
/// `evictUnderBudgetLocked` immediately so the new ceiling is
|
|
210
|
-
///
|
|
211
|
-
///
|
|
272
|
+
/// `evictUnderBudgetLocked` immediately so the new ceiling is enforced
|
|
273
|
+
/// before the call returns. On grow, the new budget takes effect on the next
|
|
274
|
+
/// download — no immediate eviction.
|
|
212
275
|
///
|
|
213
|
-
/// `protectedUrls
|
|
214
|
-
///
|
|
215
|
-
///
|
|
216
|
-
///
|
|
217
|
-
/// mid-playback. TrackPlayer passes
|
|
218
|
-
/// `tracks[currentTrackIndex...].map { $0.url }` so the entire
|
|
219
|
-
/// active tail (current + buffered-ahead) is safe. Always keyed
|
|
220
|
-
/// on URL — `track.id` is consumer-supplied + unreliable.
|
|
276
|
+
/// `protectedUrls` names the source URLs whose items the engine currently
|
|
277
|
+
/// holds. Without it a shrink can evict the file backing a live item and
|
|
278
|
+
/// surface as `AVPlayerItemFailedToPlayToEndTime` mid-playback. Always keyed
|
|
279
|
+
/// on URL: `track.id` is consumer-supplied and unreliable.
|
|
221
280
|
public func setMaxSizeBytes(_ newSize: UInt64, protectedUrls: Set<String> = []) {
|
|
222
281
|
let urlsToDeleteAfterUnlock: [URL] = queue.sync {
|
|
223
282
|
let shrinking = newSize < self.maxBytes
|
|
@@ -254,30 +313,35 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
254
313
|
}
|
|
255
314
|
}
|
|
256
315
|
|
|
257
|
-
/// Number of cache index
|
|
258
|
-
///
|
|
259
|
-
///
|
|
260
|
-
/// `
|
|
261
|
-
///
|
|
262
|
-
///
|
|
316
|
+
/// Number of entries the cache index holds (queue-agnostic). Mirrors the
|
|
317
|
+
/// semantics of `currentSizeBytes()` — cache state, independent of any
|
|
318
|
+
/// current queue. Used by `TrackPlayer.buildCacheStatusSnapshot` for the
|
|
319
|
+
/// `tracksFullyCached` field, so the count reflects the cache itself, not
|
|
320
|
+
/// the intersection with `self.tracks`.
|
|
321
|
+
///
|
|
322
|
+
/// The index is the source of truth, exactly as it is for
|
|
323
|
+
/// `currentSizeBytes()`, so this is O(1) and holds the lock briefly: a
|
|
324
|
+
/// cache-status subscriber has it fire after every prefetch iteration, and
|
|
325
|
+
/// stating the disk per entry would park the caller on the queue committing
|
|
326
|
+
/// those downloads. It therefore over-reports a file removed out from under
|
|
327
|
+
/// the index. Where the answer has to be exact about a single file — deciding
|
|
328
|
+
/// whether to play from cache — `isFullyCached(url:)` stats it.
|
|
329
|
+
///
|
|
330
|
+
/// Android's counterpart verifies bytes on disk per key, because Media3
|
|
331
|
+
/// caches partial chunks and counting keys there would over-report a
|
|
332
|
+
/// half-downloaded track rather than a deleted one.
|
|
263
333
|
public func fullyCachedCount() -> Int {
|
|
264
|
-
return queue.sync {
|
|
265
|
-
self.index.entries.reduce(into: 0) { acc, entry in
|
|
266
|
-
let fileURL = cacheDirectory.appendingPathComponent(entry.fileName)
|
|
267
|
-
if FileManager.default.fileExists(atPath: fileURL.path) {
|
|
268
|
-
acc += 1
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
334
|
+
return queue.sync { self.index.entries.count }
|
|
272
335
|
}
|
|
273
336
|
|
|
274
337
|
/// Wipe every cached file + reset the index.
|
|
275
338
|
///
|
|
276
|
-
///
|
|
277
|
-
///
|
|
278
|
-
///
|
|
279
|
-
///
|
|
280
|
-
///
|
|
339
|
+
/// Safe during playback. A playing item is bound to the read-through server
|
|
340
|
+
/// rather than to a cache file, so a track whose file is deleted underneath
|
|
341
|
+
/// it reads through to the origin on its next request instead of failing —
|
|
342
|
+
/// the cost is re-fetching the remainder, not an
|
|
343
|
+
/// `AVPlayerItemFailedToPlayToEndTime`. A response already in flight
|
|
344
|
+
/// completes, because the open handle holds the unlinked inode.
|
|
281
345
|
public func clear() {
|
|
282
346
|
let urlsToDelete: [URL] = queue.sync {
|
|
283
347
|
let urls = self.index.entries.map {
|
|
@@ -299,14 +363,14 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
299
363
|
|
|
300
364
|
// MARK: - Lookup
|
|
301
365
|
|
|
302
|
-
/// Return the local file URL for `url` (the source URL —
|
|
303
|
-
///
|
|
304
|
-
///
|
|
305
|
-
///
|
|
306
|
-
/// `
|
|
307
|
-
///
|
|
308
|
-
///
|
|
309
|
-
///
|
|
366
|
+
/// Return the local file URL for `url` (the source URL — the same
|
|
367
|
+
/// string `isFullyCached(url:)` keys on) if the cache is enabled and a
|
|
368
|
+
/// fully-downloaded entry exists, otherwise nil. Pure read — does NOT touch the LRU
|
|
369
|
+
/// access tick. The external production caller is the read-through server's
|
|
370
|
+
/// per-request `cachedFileForOrigin` lookup (built in
|
|
371
|
+
/// `TrackPlayer.startReadThroughServer`), which calls `touch(url:)`
|
|
372
|
+
/// once per token on its first disk hit, so a track's many range
|
|
373
|
+
/// requests cost one index write rather than one per read.
|
|
310
374
|
///
|
|
311
375
|
/// Defensive: if the file got pruned out from under us (consumer
|
|
312
376
|
/// ran `rm -rf tmp`, or eviction then crashed before
|
|
@@ -319,6 +383,9 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
319
383
|
/// for the lifetime of this process.
|
|
320
384
|
public func cachedURL(forUrl url: String) -> URL? {
|
|
321
385
|
return queue.sync {
|
|
386
|
+
// A disabled cache serves nothing: every request goes to the origin,
|
|
387
|
+
// whatever is on disk. The files stay for the next enable.
|
|
388
|
+
guard self.isEnabled else { return nil }
|
|
322
389
|
guard let entryIdx = self.index.entries.firstIndex(where: { $0.trackId == url }) else {
|
|
323
390
|
return nil
|
|
324
391
|
}
|
|
@@ -379,7 +446,12 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
379
446
|
// that survived `disable()` + tried to download against the
|
|
380
447
|
// (intentionally NOT invalidated) session still skips the
|
|
381
448
|
// request rather than competing with a re-enable cycle.
|
|
382
|
-
|
|
449
|
+
//
|
|
450
|
+
// The cellular policy is read here too, so one download cannot straddle a
|
|
451
|
+
// change: it is issued wholly under the policy in force when it started.
|
|
452
|
+
let (enabled, cellularAllowed) = queue.sync {
|
|
453
|
+
(self.isEnabled, self.cellularAllowed)
|
|
454
|
+
}
|
|
383
455
|
guard enabled else { return }
|
|
384
456
|
|
|
385
457
|
// Cache key = source URL. The `Entry` struct's `trackId` field
|
|
@@ -407,18 +479,34 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
407
479
|
// contract in `LookaheadCachePrefetcher` (failed tracks retry
|
|
408
480
|
// on the next reschedule).
|
|
409
481
|
let dedupeKey = track.url
|
|
410
|
-
|
|
411
|
-
|
|
482
|
+
// Re-read `isEnabled` in the same hop that claims the dedup slot. The read
|
|
483
|
+
// above is separated from here by two more hops, and `tearDown` takes this
|
|
484
|
+
// same queue to invalidate the session — asking an invalidated session for
|
|
485
|
+
// a task raises an Objective-C exception Swift cannot catch. Admission and
|
|
486
|
+
// the enabled check have to be one decision, not two.
|
|
487
|
+
let admitted: Bool = queue.sync {
|
|
488
|
+
guard self.isEnabled, !self.pendingDownloads.contains(dedupeKey) else {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
412
491
|
self.pendingDownloads.insert(dedupeKey)
|
|
413
|
-
|
|
492
|
+
// Entered under the same decision, so `tearDown` sees this download as
|
|
493
|
+
// in flight from the moment it is admitted.
|
|
494
|
+
self.inFlightDownloads.enter()
|
|
495
|
+
return true
|
|
414
496
|
}
|
|
415
|
-
|
|
497
|
+
guard admitted else { return }
|
|
416
498
|
defer {
|
|
499
|
+
inFlightDownloads.leave()
|
|
417
500
|
queue.async { _ = self.pendingDownloads.remove(dedupeKey) }
|
|
418
501
|
}
|
|
419
502
|
|
|
503
|
+
// Teardown cancels the prefetcher before invalidating the session, and
|
|
504
|
+
// cancellation is cooperative — this is the checkpoint that honours it, so
|
|
505
|
+
// a task admitted a moment before teardown does not reach the session.
|
|
506
|
+
try Task.checkCancellation()
|
|
507
|
+
|
|
420
508
|
let (tempLocation, sizeBytes, contentType) = try await performDownload(
|
|
421
|
-
url: url, headers: headers
|
|
509
|
+
url: url, headers: headers, allowsCellularAccess: cellularAllowed
|
|
422
510
|
)
|
|
423
511
|
try Task.checkCancellation()
|
|
424
512
|
|
|
@@ -431,16 +519,35 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
431
519
|
)
|
|
432
520
|
}
|
|
433
521
|
|
|
434
|
-
///
|
|
435
|
-
///
|
|
436
|
-
///
|
|
437
|
-
|
|
438
|
-
|
|
522
|
+
/// Remove a rejected download's temp file. Cleanup failures are logged
|
|
523
|
+
/// rather than thrown — the caller is already failing the download, and a
|
|
524
|
+
/// leaked temp file should be diagnosable without masking that reason.
|
|
525
|
+
private func discardTemp(at location: URL, reason: String) {
|
|
526
|
+
do {
|
|
527
|
+
try FileManager.default.removeItem(at: location)
|
|
528
|
+
} catch {
|
|
529
|
+
NSLog(
|
|
530
|
+
"[LookaheadCache] temp-file cleanup failed (%@) at %@: %@",
|
|
531
|
+
reason, location.path, String(describing: error)
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Run the URLSession download and HTTP-status validation. Returns the temp
|
|
537
|
+
/// location URLSession wrote to plus the measured size in bytes, so the
|
|
538
|
+
/// commit step does not re-stat the file. Throws
|
|
539
|
+
/// `LookaheadCacheError.httpError` for non-2xx responses, cleaning up the
|
|
540
|
+
/// temp file before throwing.
|
|
439
541
|
private func performDownload(
|
|
440
|
-
url: URL, headers: [String: String]
|
|
542
|
+
url: URL, headers: [String: String]?, allowsCellularAccess: Bool
|
|
441
543
|
) async throws -> (tempLocation: URL, sizeBytes: UInt64, contentType: String?) {
|
|
442
544
|
var request = URLRequest(url: url)
|
|
443
545
|
headers?.forEach { request.addValue($1, forHTTPHeaderField: $0) }
|
|
546
|
+
// Prefetch downloads whole upcoming tracks, so on a metered connection it
|
|
547
|
+
// can spend a lot of data on tracks the listener skips past. `false` here
|
|
548
|
+
// is an unconditional refusal — the request fails with
|
|
549
|
+
// `NSURLErrorDataNotAllowed` rather than falling back to cellular.
|
|
550
|
+
request.allowsCellularAccess = allowsCellularAccess
|
|
444
551
|
|
|
445
552
|
let (tempLocation, response) = try await urlSession.download(for: request)
|
|
446
553
|
|
|
@@ -462,8 +569,35 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
462
569
|
)
|
|
463
570
|
}
|
|
464
571
|
|
|
465
|
-
|
|
466
|
-
|
|
572
|
+
// A 206 to a request that carried no `Range` is a partial body nobody
|
|
573
|
+
// asked for — short by construction.
|
|
574
|
+
if httpResponse.statusCode == 206,
|
|
575
|
+
request.value(forHTTPHeaderField: "Range") == nil {
|
|
576
|
+
discardTemp(at: tempLocation, reason: "unexpected 206")
|
|
577
|
+
throw LookaheadCacheError.unexpectedPartialContent
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Read the size explicitly rather than defaulting a failure to zero: a
|
|
581
|
+
// `?? 0` turns an unreadable attribute on a perfectly good download into
|
|
582
|
+
// a rejection once the size is checked below.
|
|
583
|
+
guard let attrs = try? FileManager.default.attributesOfItem(atPath: tempLocation.path),
|
|
584
|
+
let sizeNumber = attrs[.size] as? NSNumber else {
|
|
585
|
+
discardTemp(at: tempLocation, reason: "size unreadable")
|
|
586
|
+
throw LookaheadCacheError.sizeUnreadable
|
|
587
|
+
}
|
|
588
|
+
let sizeBytes = sizeNumber.uint64Value
|
|
589
|
+
|
|
590
|
+
// Reject only when the body is SHORTER than declared. Stated this way the
|
|
591
|
+
// check is immune to Content-Encoding: a decoded body longer than the
|
|
592
|
+
// declared compressed length is not a truncation. `expectedContentLength`
|
|
593
|
+
// is -1 when the server chunked the response, which carries no length to
|
|
594
|
+
// compare against.
|
|
595
|
+
let expected = httpResponse.expectedContentLength
|
|
596
|
+
if expected > 0, sizeBytes < UInt64(expected) {
|
|
597
|
+
discardTemp(at: tempLocation, reason: "short body")
|
|
598
|
+
throw LookaheadCacheError.truncatedBody(expected: expected, received: sizeBytes)
|
|
599
|
+
}
|
|
600
|
+
|
|
467
601
|
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")
|
|
468
602
|
return (tempLocation, sizeBytes, contentType)
|
|
469
603
|
}
|
|
@@ -546,7 +680,7 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
546
680
|
entries: self.index.entries,
|
|
547
681
|
maxBytes: self.maxBytes,
|
|
548
682
|
useFifoOrder: self.evictionPolicy == .fifo,
|
|
549
|
-
protectedUrls: protectedUrls
|
|
683
|
+
protectedUrls: protectedUrls.union(self.liveProtectedUrls)
|
|
550
684
|
)
|
|
551
685
|
if evicted.isEmpty { return [] }
|
|
552
686
|
let evictedIds = Set(evicted.map { $0.trackId })
|
|
@@ -682,7 +816,7 @@ public final class LookaheadCache: @unchecked Sendable {
|
|
|
682
816
|
/// Map an HTTP `Content-Type` value to a file-extension hint that
|
|
683
817
|
/// AVFoundation recognises. Returns `nil` for unknown / missing
|
|
684
818
|
/// types so the caller can fall through to URL-derived hints.
|
|
685
|
-
/// `application/octet-stream` (default
|
|
819
|
+
/// `application/octet-stream` (the default on some media servers)
|
|
686
820
|
/// is treated as unknown — caller falls back to URL hints.
|
|
687
821
|
@_spi(QueuePlayerTests)
|
|
688
822
|
public static func extensionFromMimeType(_ contentType: String?) -> String? {
|
|
@@ -807,6 +941,16 @@ public enum LookaheadCacheError: Error {
|
|
|
807
941
|
/// `cachedURL` lookups return nil and the cache-status snapshot
|
|
808
942
|
/// lies. See `download(track:headers:)` for the throw site.
|
|
809
943
|
case moveFailed(underlying: Error)
|
|
944
|
+
/// Body was shorter than the response declared. Committing it would put a
|
|
945
|
+
/// truncated file behind a "fully cached" entry, and the item built from
|
|
946
|
+
/// it fails to decode on every play and every retry.
|
|
947
|
+
case truncatedBody(expected: Int64, received: UInt64)
|
|
948
|
+
/// Server answered a partial body to a request that asked for the whole
|
|
949
|
+
/// file, so the download is short by construction.
|
|
950
|
+
case unexpectedPartialContent
|
|
951
|
+
/// The downloaded file's size could not be read, so the truncation check
|
|
952
|
+
/// cannot run; the file is discarded rather than committed unchecked.
|
|
953
|
+
case sizeUnreadable
|
|
810
954
|
}
|
|
811
955
|
|
|
812
956
|
/// Abstraction over `LookaheadCache.download` so the prefetcher
|
|
@@ -55,21 +55,25 @@ public final class LookaheadCachePrefetcher {
|
|
|
55
55
|
|
|
56
56
|
/// Headers + UA snapshot used for prefetch downloads. Read on
|
|
57
57
|
/// every download call so a runtime-config update can swap
|
|
58
|
-
/// the snapshot atomically.
|
|
59
|
-
///
|
|
60
|
-
/// hops to main first).
|
|
58
|
+
/// the snapshot atomically. Written from `configure`, which runs on the
|
|
59
|
+
/// same queue this prefetcher is confined to.
|
|
61
60
|
internal var configHeaders: [String: String]?
|
|
62
61
|
internal var configUserAgent: String?
|
|
63
62
|
|
|
64
|
-
///
|
|
65
|
-
///
|
|
66
|
-
///
|
|
67
|
-
///
|
|
68
|
-
///
|
|
63
|
+
/// The queue every access to this prefetcher's state runs on. Nothing here
|
|
64
|
+
/// touches UI — the work is URLSession downloads and file IO through a cache
|
|
65
|
+
/// that serialises itself — so it is confined to whichever queue owns player
|
|
66
|
+
/// state rather than to main. That keeps it on the same queue as the callers
|
|
67
|
+
/// which cancel and tear it down, whose ordering against the cache's
|
|
68
|
+
/// URLSession invalidation has to hold.
|
|
69
|
+
private let queue: DispatchQueue
|
|
70
|
+
|
|
71
|
+
/// Source URL of the in-flight download, or `nil` when nothing is
|
|
72
|
+
/// mid-flight. The orchestrator reads it to decide whether to cancel, and
|
|
73
|
+
/// `reschedule` to know which URL is already covered.
|
|
69
74
|
///
|
|
70
|
-
///
|
|
71
|
-
///
|
|
72
|
-
/// chain.
|
|
75
|
+
/// Guarded by `queue`, not by an isolation annotation: a `@MainActor` or
|
|
76
|
+
/// actor declaration here would cascade through every caller.
|
|
73
77
|
private var inFlightUrl: String?
|
|
74
78
|
|
|
75
79
|
/// The Task running the in-flight download. Held so a `reschedule`
|
|
@@ -139,10 +143,12 @@ public final class LookaheadCachePrefetcher {
|
|
|
139
143
|
/// each download.
|
|
140
144
|
public init(
|
|
141
145
|
cache: LookaheadCache,
|
|
146
|
+
confinedTo queue: DispatchQueue,
|
|
142
147
|
configHeaders: [String: String]? = nil,
|
|
143
148
|
configUserAgent: String? = nil
|
|
144
149
|
) {
|
|
145
150
|
self.cache = cache
|
|
151
|
+
self.queue = queue
|
|
146
152
|
self.configHeaders = configHeaders
|
|
147
153
|
self.configUserAgent = configUserAgent
|
|
148
154
|
}
|
|
@@ -154,10 +160,12 @@ public final class LookaheadCachePrefetcher {
|
|
|
154
160
|
@_spi(QueuePlayerTests)
|
|
155
161
|
public init(
|
|
156
162
|
downloader: LookaheadCacheDownloader,
|
|
163
|
+
confinedTo queue: DispatchQueue,
|
|
157
164
|
configHeaders: [String: String]? = nil,
|
|
158
165
|
configUserAgent: String? = nil
|
|
159
166
|
) {
|
|
160
167
|
self.cache = downloader
|
|
168
|
+
self.queue = queue
|
|
161
169
|
self.configHeaders = configHeaders
|
|
162
170
|
self.configUserAgent = configUserAgent
|
|
163
171
|
}
|
|
@@ -177,7 +185,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
177
185
|
/// 2. `cache.tearDown()` — URLSession invalidated; any bytes
|
|
178
186
|
/// still on the wire abort.
|
|
179
187
|
public func cancel() {
|
|
180
|
-
dispatchPrecondition(condition: .onQueue(
|
|
188
|
+
dispatchPrecondition(condition: .onQueue(queue))
|
|
181
189
|
pendingTargets.removeAll()
|
|
182
190
|
inFlightTask?.cancel()
|
|
183
191
|
inFlightTask = nil
|
|
@@ -195,7 +203,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
195
203
|
/// `LookaheadCache.tearDown` so the prefetch loop can observe
|
|
196
204
|
/// cancellation cleanly before its underlying URLSession goes away.
|
|
197
205
|
public func tearDown() {
|
|
198
|
-
dispatchPrecondition(condition: .onQueue(
|
|
206
|
+
dispatchPrecondition(condition: .onQueue(queue))
|
|
199
207
|
cancel()
|
|
200
208
|
}
|
|
201
209
|
|
|
@@ -215,7 +223,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
215
223
|
currentIndex: Int,
|
|
216
224
|
lookaheadCount: Int? = nil
|
|
217
225
|
) {
|
|
218
|
-
dispatchPrecondition(condition: .onQueue(
|
|
226
|
+
dispatchPrecondition(condition: .onQueue(queue))
|
|
219
227
|
let count = lookaheadCount ?? defaultLookaheadCount
|
|
220
228
|
var desired = computeTargets(
|
|
221
229
|
tracks: tracks, currentIndex: currentIndex, lookaheadCount: count
|
|
@@ -307,13 +315,6 @@ public final class LookaheadCachePrefetcher {
|
|
|
307
315
|
return orchestrator != nil || inFlightTask != nil
|
|
308
316
|
}
|
|
309
317
|
|
|
310
|
-
/// Test introspection — count of URLs queued behind the in-flight
|
|
311
|
-
/// download.
|
|
312
|
-
@_spi(QueuePlayerTests)
|
|
313
|
-
public var pendingCount: Int {
|
|
314
|
-
return pendingTargets.count
|
|
315
|
-
}
|
|
316
|
-
|
|
317
318
|
/// Test introspection — await the orchestrator so test
|
|
318
319
|
/// assertions can fire after the prefetch chain drains.
|
|
319
320
|
@_spi(QueuePlayerTests)
|
|
@@ -323,6 +324,15 @@ public final class LookaheadCachePrefetcher {
|
|
|
323
324
|
|
|
324
325
|
// MARK: - Orchestrator
|
|
325
326
|
|
|
327
|
+
/// Run `body` on the confinement queue and await its result. The orchestrator
|
|
328
|
+
/// itself runs on the cooperative pool, so every touch of this object's state
|
|
329
|
+
/// comes back through here. Bodies keep their own `[weak self]` capture.
|
|
330
|
+
private func onQueue<T>(_ body: @escaping () -> T) async -> T {
|
|
331
|
+
await withCheckedContinuation { (cont: CheckedContinuation<T, Never>) in
|
|
332
|
+
queue.async { cont.resume(returning: body()) }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
326
336
|
/// Launch a fresh orchestrator if none is running. Capturing
|
|
327
337
|
/// `self` weakly keeps the prefetcher releaseable mid-loop —
|
|
328
338
|
/// tearDown nils the orchestrator reference and the loop body
|
|
@@ -336,7 +346,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
336
346
|
isOrchestratorRunning = true
|
|
337
347
|
orchestrator = Task { [weak self] in
|
|
338
348
|
await self?.runOrchestrator()
|
|
339
|
-
await
|
|
349
|
+
await self?.onQueue { [weak self] in
|
|
340
350
|
guard let self else { return }
|
|
341
351
|
// Identity check: only clear ownership if this trailing hook
|
|
342
352
|
// belongs to the still-current orchestrator. If a `cancel()`
|
|
@@ -362,11 +372,11 @@ public final class LookaheadCachePrefetcher {
|
|
|
362
372
|
}
|
|
363
373
|
|
|
364
374
|
/// Drain `pendingTargets` one URL at a time. Pops the next track
|
|
365
|
-
/// on
|
|
375
|
+
/// on its owner queue, downloads it on the cooperative pool, then loops.
|
|
366
376
|
/// Exits when `pendingTargets` is empty or the Task is cancelled.
|
|
367
377
|
private func runOrchestrator() async {
|
|
368
378
|
while !Task.isCancelled {
|
|
369
|
-
let next: TrackItem? = await
|
|
379
|
+
let next: TrackItem? = await onQueue { [weak self] () -> TrackItem? in
|
|
370
380
|
guard let self = self else { return nil }
|
|
371
381
|
if self.pendingTargets.isEmpty { return nil }
|
|
372
382
|
return self.pendingTargets.removeFirst()
|
|
@@ -378,11 +388,11 @@ public final class LookaheadCachePrefetcher {
|
|
|
378
388
|
// `reschedule` enqueued the URL just before a download via a
|
|
379
389
|
// different path completed.
|
|
380
390
|
if cache.isFullyCached(url: track.url) {
|
|
381
|
-
await
|
|
391
|
+
await onQueue { [weak self] in self?.onTrackProcessed?() }
|
|
382
392
|
continue
|
|
383
393
|
}
|
|
384
394
|
|
|
385
|
-
let headers = await
|
|
395
|
+
let headers = await onQueue { [weak self] () -> [String: String] in
|
|
386
396
|
guard let self = self else { return [:] }
|
|
387
397
|
return Self.merge(
|
|
388
398
|
headers: self.configHeaders, userAgent: self.configUserAgent
|
|
@@ -392,7 +402,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
392
402
|
// Build the Task suspended via an explicit start barrier so
|
|
393
403
|
// `inFlightUrl` / `inFlightTask` publish before the download
|
|
394
404
|
// body executes. Without the barrier the eager-start Task can
|
|
395
|
-
// race the
|
|
405
|
+
// race the queue-hop setter — observers polling the
|
|
396
406
|
// currently-downloading URL after the gated downloader fires
|
|
397
407
|
// its onStart can otherwise see a stale `nil`.
|
|
398
408
|
let startBarrier = AsyncBarrier()
|
|
@@ -400,7 +410,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
400
410
|
await startBarrier.wait()
|
|
401
411
|
try await cache.download(track: track, headers: headers)
|
|
402
412
|
}
|
|
403
|
-
await
|
|
413
|
+
await onQueue { [weak self] in
|
|
404
414
|
self?.inFlightUrl = track.url
|
|
405
415
|
self?.inFlightTask = downloadTask
|
|
406
416
|
}
|
|
@@ -415,7 +425,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
415
425
|
logSwallowed(track: track, error: error)
|
|
416
426
|
}
|
|
417
427
|
|
|
418
|
-
await
|
|
428
|
+
await onQueue { [weak self] in
|
|
419
429
|
self?.inFlightUrl = nil
|
|
420
430
|
self?.inFlightTask = nil
|
|
421
431
|
self?.onTrackProcessed?()
|
|
@@ -434,14 +444,16 @@ public final class LookaheadCachePrefetcher {
|
|
|
434
444
|
private static func merge(
|
|
435
445
|
headers: [String: String]?, userAgent: String?
|
|
436
446
|
) -> [String: String] {
|
|
437
|
-
|
|
438
|
-
if let ua = userAgent, !ua.isEmpty {
|
|
439
|
-
merged["User-Agent"] = ua
|
|
440
|
-
}
|
|
441
|
-
return merged
|
|
447
|
+
AVQueueBuilder.mergedHeaders(headers: headers, userAgent: userAgent)
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
private func logSwallowed(track: TrackItem, error: Error) {
|
|
451
|
+
// A consumer who turned `allowsCellularAccess` off gets this on every
|
|
452
|
+
// reschedule made while the device is on cellular. It is the policy
|
|
453
|
+
// working, not a fault, so it does not go to the log.
|
|
454
|
+
let nsError = error as NSError
|
|
455
|
+
if nsError.domain == NSURLErrorDomain,
|
|
456
|
+
nsError.code == NSURLErrorDataNotAllowed { return }
|
|
445
457
|
// Log by source URL (the trustworthy field). `track.id` is
|
|
446
458
|
// optional consumer data — a missing or empty value would
|
|
447
459
|
// produce an unhelpful log line.
|
|
@@ -460,7 +472,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
460
472
|
}
|
|
461
473
|
|
|
462
474
|
/// Internal one-shot barrier used by the orchestrator so the
|
|
463
|
-
/// in-flight URL publishes on
|
|
475
|
+
/// in-flight URL publishes on the owner queue BEFORE the download body
|
|
464
476
|
/// executes. `release()` is idempotent + non-blocking; `wait()`
|
|
465
477
|
/// suspends until the first `release()` and resumes immediately
|
|
466
478
|
/// for any subsequent `wait()` (no producer needed).
|
|
@@ -469,7 +481,7 @@ public final class LookaheadCachePrefetcher {
|
|
|
469
481
|
/// the download Deferred to achieve the same publish-before-body
|
|
470
482
|
/// semantics. Swift Tasks have no equivalent lazy-start option, so
|
|
471
483
|
/// the orchestrator constructs the Task suspended on this barrier,
|
|
472
|
-
/// publishes `inFlightUrl` / `inFlightTask` on
|
|
484
|
+
/// publishes `inFlightUrl` / `inFlightTask` on the owner queue, then releases.
|
|
473
485
|
private final class AsyncBarrier: @unchecked Sendable {
|
|
474
486
|
private let lock = NSLock()
|
|
475
487
|
private var continuation: CheckedContinuation<Void, Never>?
|