react-native-queue-player 1.2.0 → 2.0.1
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 +111 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +78 -30
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
- 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 +13 -5
- 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 +985 -276
- 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 +84 -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/MediaResponseCheckTest.kt +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
- 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 +122 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +291 -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 +672 -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 +229 -58
- package/ios/LookaheadCachePrefetcher.swift +48 -36
- package/ios/MediaResponseCheck.swift +122 -0
- 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/MediaResponseCheckTests.swift +128 -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 +418 -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 +441 -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 +206 -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 +1042 -0
- package/ios/TrackPlayer+EventsWiring.swift +173 -0
- package/ios/TrackPlayer+Lifecycle.swift +982 -0
- package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
- package/ios/TrackPlayer+Queue.swift +951 -0
- package/ios/TrackPlayer+Recovery.swift +213 -0
- package/ios/TrackPlayer+Skip.swift +465 -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 +272 -0
- package/ios/TrackPlayer+Window.swift +227 -0
- package/ios/TrackPlayer.swift +331 -4619
- package/ios/Visualizer.swift +6 -5
- package/ios/tests-harness/Podfile +1 -1
- package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
- package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
- package/lib/module/hooks/useActiveTrack.js +29 -22
- package/lib/module/hooks/useActiveTrack.js.map +1 -1
- package/lib/module/hooks/useCast.js +8 -26
- package/lib/module/hooks/useCast.js.map +1 -1
- package/lib/module/hooks/useEqualizer.js +19 -12
- package/lib/module/hooks/useEqualizer.js.map +1 -1
- package/lib/module/hooks/useLookaheadCache.js +3 -7
- package/lib/module/hooks/useLookaheadCache.js.map +1 -1
- package/lib/module/hooks/useQueue.js +66 -19
- package/lib/module/hooks/useQueue.js.map +1 -1
- package/lib/module/index.js +6 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/queueDelta.js +41 -0
- package/lib/module/queueDelta.js.map +1 -0
- package/lib/module/types.js +28 -5
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
- package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
- package/lib/typescript/hooks/useCast.d.ts +6 -1
- package/lib/typescript/hooks/useCast.d.ts.map +1 -1
- package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
- package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
- package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +4 -4
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/queueDelta.d.ts +10 -0
- package/lib/typescript/queueDelta.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +168 -12
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
- package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
- package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
- package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
- package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
- package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
- package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
- package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
- package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
- package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
- package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
- package/package.json +8 -8
- package/src/TrackPlayer.nitro.ts +53 -27
- package/src/hooks/useActiveTrack.ts +29 -22
- package/src/hooks/useCast.ts +14 -15
- package/src/hooks/useEqualizer.ts +19 -12
- package/src/hooks/useLookaheadCache.ts +3 -7
- package/src/hooks/useQueue.ts +66 -17
- package/src/index.ts +13 -3
- package/src/queueDelta.ts +41 -0
- package/src/types.ts +169 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
- package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
- package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
- package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
- package/ios/MetadataReader.swift +0 -555
- package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
- package/ios/Tests/MetadataReaderTests.swift +0 -603
- package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
- package/ios/Tests/VoiceDonationTests.swift +0 -26
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
- /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
/// Resolution of an HTTP `Range` header against a known file size.
|
|
5
|
+
enum MediaByteRange: Equatable {
|
|
6
|
+
/// No Range header, or one the server ignores (malformed / invalid
|
|
7
|
+
/// bounds) — serve the whole file with `200`.
|
|
8
|
+
case full
|
|
9
|
+
/// A satisfiable single range with inclusive bounds — serve `206`.
|
|
10
|
+
case partial(start: Int64, end: Int64)
|
|
11
|
+
/// A syntactically-valid range that starts past EOF — serve `416`.
|
|
12
|
+
case unsatisfiable
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Parsed view of an incoming request: the method, the request target,
|
|
16
|
+
/// and the one header the file server acts on (`Range`). Built from the
|
|
17
|
+
/// raw header block (everything before the `\r\n\r\n` terminator).
|
|
18
|
+
struct MediaHTTPRequest: Equatable {
|
|
19
|
+
let method: String
|
|
20
|
+
let path: String
|
|
21
|
+
let rangeHeader: String?
|
|
22
|
+
|
|
23
|
+
/// The `<token>` from a `/media/<32-hex>` path, or `nil` when the path
|
|
24
|
+
/// is anything else. The token is both the routing key (which file) and
|
|
25
|
+
/// the access secret (high-entropy, unenumerable) for the LAN-reachable
|
|
26
|
+
/// server.
|
|
27
|
+
var mediaToken: String? {
|
|
28
|
+
let prefix = "/media/"
|
|
29
|
+
guard path.hasPrefix(prefix) else { return nil }
|
|
30
|
+
let token = String(path.dropFirst(prefix.count))
|
|
31
|
+
guard token.count == 32,
|
|
32
|
+
token.allSatisfy({ ($0 >= "0" && $0 <= "9") || ($0 >= "a" && $0 <= "f") })
|
|
33
|
+
else { return nil }
|
|
34
|
+
return token
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
init?(headerData: Data) {
|
|
38
|
+
guard let text = String(data: headerData, encoding: .utf8) else { return nil }
|
|
39
|
+
var lines = text.components(separatedBy: "\r\n")
|
|
40
|
+
guard !lines.isEmpty else { return nil }
|
|
41
|
+
let requestLine = lines.removeFirst().split(separator: " ")
|
|
42
|
+
guard requestLine.count >= 2 else { return nil }
|
|
43
|
+
self.method = String(requestLine[0]).uppercased()
|
|
44
|
+
// Strip any query string — the file path never carries one, and the
|
|
45
|
+
// token match below expects the bare path.
|
|
46
|
+
let target = String(requestLine[1])
|
|
47
|
+
self.path = target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? target
|
|
48
|
+
|
|
49
|
+
var range: String?
|
|
50
|
+
for line in lines where !line.isEmpty {
|
|
51
|
+
guard let colon = line.firstIndex(of: ":") else { continue }
|
|
52
|
+
let name = line[line.startIndex..<colon].trimmingCharacters(in: .whitespaces).lowercased()
|
|
53
|
+
if name == "range" {
|
|
54
|
+
range = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
self.rangeHeader = range
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// What a `/media/<token>` request resolves to.
|
|
62
|
+
///
|
|
63
|
+
/// Two servers share this connection. The cast server registers local files it
|
|
64
|
+
/// was handed by the consumer; the playback cache registers remote origins and
|
|
65
|
+
/// lets the cache answer for them when it holds a complete copy. Keeping the
|
|
66
|
+
/// arbitration in the resolver is what keeps `LookaheadCache` out of this file.
|
|
67
|
+
enum MediaSource {
|
|
68
|
+
case file(URL, contentType: String)
|
|
69
|
+
case readThrough(ReadThroughSource)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// An origin to read through, with the cache's complete copy when it has one.
|
|
73
|
+
struct ReadThroughSource {
|
|
74
|
+
let origin: URL
|
|
75
|
+
/// The consumer's configured headers, re-applied on the origin leg.
|
|
76
|
+
let headers: [String: String]
|
|
77
|
+
/// The cache's complete copy of `origin`, resolved at request time. Nil when
|
|
78
|
+
/// the cache does not hold the whole file — there is no partial serving.
|
|
79
|
+
let cachedFile: URL?
|
|
80
|
+
let session: URLSession
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Reconciles a data task's body with its response heads, so that a task the
|
|
84
|
+
/// system re-issued on resume delivers every byte once.
|
|
85
|
+
///
|
|
86
|
+
/// A resumed `URLSessionDataTask` is not guaranteed to continue rather than
|
|
87
|
+
/// re-issue the request. A second head that matches the first means the body
|
|
88
|
+
/// is starting over: the bytes the first transfer already delivered are
|
|
89
|
+
/// dropped from the restarted one. A second head that differs is a different
|
|
90
|
+
/// resource and cannot be stitched.
|
|
91
|
+
struct OriginRestartStitcher {
|
|
92
|
+
enum HeadVerdict: Equatable {
|
|
93
|
+
/// The first head, or a re-issued request answered the same way.
|
|
94
|
+
case accept
|
|
95
|
+
/// A re-issued request answered differently.
|
|
96
|
+
case reject
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private var first: HTTPURLResponse?
|
|
100
|
+
private(set) var deliveredBytes = 0
|
|
101
|
+
private var skip = 0
|
|
102
|
+
|
|
103
|
+
mutating func head(_ response: HTTPURLResponse) -> HeadVerdict {
|
|
104
|
+
guard let first else {
|
|
105
|
+
self.first = response
|
|
106
|
+
return .accept
|
|
107
|
+
}
|
|
108
|
+
guard Self.sameAnswer(first, response) else { return .reject }
|
|
109
|
+
skip = deliveredBytes
|
|
110
|
+
return .accept
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// The part of `data` the client has not yet been sent; empty while a
|
|
114
|
+
/// restarted transfer is still repeating what was delivered.
|
|
115
|
+
mutating func body(_ data: Data) -> Data {
|
|
116
|
+
var chunk = data
|
|
117
|
+
if skip > 0 {
|
|
118
|
+
let drop = min(skip, chunk.count)
|
|
119
|
+
skip -= drop
|
|
120
|
+
chunk = chunk.dropFirst(drop)
|
|
121
|
+
}
|
|
122
|
+
deliveredBytes += chunk.count
|
|
123
|
+
return chunk
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static let identityHeaders = ["Content-Range", "Content-Length", "ETag", "Last-Modified"]
|
|
127
|
+
|
|
128
|
+
private static func sameAnswer(_ a: HTTPURLResponse, _ b: HTTPURLResponse) -> Bool {
|
|
129
|
+
a.statusCode == b.statusCode
|
|
130
|
+
&& identityHeaders.allSatisfy {
|
|
131
|
+
a.value(forHTTPHeaderField: $0) == b.value(forHTTPHeaderField: $0)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// One origin response delivered as the `Data` chunks URLSession produces.
|
|
137
|
+
///
|
|
138
|
+
/// The relay's own buffer is what is bounded: the task is suspended while more
|
|
139
|
+
/// than `highWater` bytes have been yielded but not yet sent to the client and
|
|
140
|
+
/// resumed under `lowWater`. Suspending a task stops its callbacks, not
|
|
141
|
+
/// necessarily its transfer — the system may keep receiving into its own
|
|
142
|
+
/// buffers — and a resumed data task may re-issue the request instead of
|
|
143
|
+
/// continuing; `OriginRestartStitcher` keeps the client's bytes exactly once
|
|
144
|
+
/// either way.
|
|
145
|
+
private final class OriginBody: NSObject, URLSessionDataDelegate {
|
|
146
|
+
let chunks: AsyncThrowingStream<Data, Error>
|
|
147
|
+
private var yield: AsyncThrowingStream<Data, Error>.Continuation?
|
|
148
|
+
private let lock = NSLock()
|
|
149
|
+
private var received: HTTPURLResponse?
|
|
150
|
+
private var failure: Error?
|
|
151
|
+
private var waiter: CheckedContinuation<URLResponse, Error>?
|
|
152
|
+
private var pendingBytes = 0
|
|
153
|
+
private var suspended = false
|
|
154
|
+
private var stitcher = OriginRestartStitcher()
|
|
155
|
+
private static let highWater = 2 * 1024 * 1024
|
|
156
|
+
private static let lowWater = 512 * 1024
|
|
157
|
+
|
|
158
|
+
/// The origin answered a re-issued request differently from the first one,
|
|
159
|
+
/// so the bytes cannot be stitched onto what was already relayed.
|
|
160
|
+
struct RestartedWithADifferentAnswer: Error {}
|
|
161
|
+
|
|
162
|
+
override init() {
|
|
163
|
+
var continuation: AsyncThrowingStream<Data, Error>.Continuation?
|
|
164
|
+
chunks = AsyncThrowingStream { continuation = $0 }
|
|
165
|
+
super.init()
|
|
166
|
+
yield = continuation
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// The response head, once the origin has answered.
|
|
170
|
+
func response() async throws -> URLResponse {
|
|
171
|
+
try await withCheckedThrowingContinuation { cont in
|
|
172
|
+
lock.lock()
|
|
173
|
+
if let received {
|
|
174
|
+
lock.unlock()
|
|
175
|
+
cont.resume(returning: received)
|
|
176
|
+
} else if let failure {
|
|
177
|
+
lock.unlock()
|
|
178
|
+
cont.resume(throwing: failure)
|
|
179
|
+
} else {
|
|
180
|
+
waiter = cont
|
|
181
|
+
lock.unlock()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// The relay has sent `count` bytes on; resume the origin when enough has
|
|
187
|
+
/// drained. The task call stays under the lock so a suspend decided on the
|
|
188
|
+
/// delegate queue and a resume decided here cannot land in the wrong order.
|
|
189
|
+
func consumed(_ count: Int, task: URLSessionDataTask) {
|
|
190
|
+
lock.lock()
|
|
191
|
+
defer { lock.unlock() }
|
|
192
|
+
pendingBytes -= count
|
|
193
|
+
if suspended, pendingBytes < Self.lowWater {
|
|
194
|
+
suspended = false
|
|
195
|
+
task.resume()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func urlSession(
|
|
200
|
+
_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
|
|
201
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
202
|
+
) {
|
|
203
|
+
lock.lock()
|
|
204
|
+
guard let http = response as? HTTPURLResponse else {
|
|
205
|
+
lock.unlock()
|
|
206
|
+
completionHandler(.cancel)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
guard stitcher.head(http) == .accept else {
|
|
210
|
+
lock.unlock()
|
|
211
|
+
completionHandler(.cancel)
|
|
212
|
+
yield?.finish(throwing: RestartedWithADifferentAnswer())
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
let waiter: CheckedContinuation<URLResponse, Error>?
|
|
216
|
+
if received == nil {
|
|
217
|
+
received = http
|
|
218
|
+
waiter = self.waiter
|
|
219
|
+
self.waiter = nil
|
|
220
|
+
} else {
|
|
221
|
+
waiter = nil
|
|
222
|
+
}
|
|
223
|
+
lock.unlock()
|
|
224
|
+
waiter?.resume(returning: http)
|
|
225
|
+
completionHandler(.allow)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
229
|
+
lock.lock()
|
|
230
|
+
let chunk = stitcher.body(data)
|
|
231
|
+
guard !chunk.isEmpty else {
|
|
232
|
+
lock.unlock()
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
pendingBytes += chunk.count
|
|
236
|
+
if !suspended, pendingBytes > Self.highWater {
|
|
237
|
+
suspended = true
|
|
238
|
+
dataTask.suspend()
|
|
239
|
+
}
|
|
240
|
+
lock.unlock()
|
|
241
|
+
yield?.yield(chunk)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
245
|
+
lock.lock()
|
|
246
|
+
let waiter = self.waiter
|
|
247
|
+
self.waiter = nil
|
|
248
|
+
if let error { failure = error }
|
|
249
|
+
lock.unlock()
|
|
250
|
+
if let error {
|
|
251
|
+
waiter?.resume(throwing: error)
|
|
252
|
+
yield?.finish(throwing: error)
|
|
253
|
+
} else {
|
|
254
|
+
yield?.finish()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Serves one resolved `MediaSource` over a single `NWConnection` as HTTP/1.1:
|
|
260
|
+
/// `GET`/`HEAD` on `/media/<token>` with `Range` support (the Cast
|
|
261
|
+
/// receiver byte-range probes + seeks). One responder per accepted
|
|
262
|
+
/// connection; it owns the connection, runs all I/O on a dedicated serial
|
|
263
|
+
/// queue, streams the body in bounded chunks (no whole-file buffering),
|
|
264
|
+
/// and reports completion via `onFinished` so the owner can drop it.
|
|
265
|
+
final class MediaHTTPConnection {
|
|
266
|
+
|
|
267
|
+
private let connection: NWConnection
|
|
268
|
+
private let resolve: (String) -> MediaSource?
|
|
269
|
+
private let onFinished: () -> Void
|
|
270
|
+
private let queue: DispatchQueue
|
|
271
|
+
/// In-flight body-streaming task, cancelled when the client hangs up so
|
|
272
|
+
/// we stop reading a range the receiver has abandoned.
|
|
273
|
+
private var streamTask: Task<Void, Never>?
|
|
274
|
+
private let stateLock = NSLock()
|
|
275
|
+
private var finished = false
|
|
276
|
+
|
|
277
|
+
/// Bound on the accumulated request header so a stalled/malicious client
|
|
278
|
+
/// can't grow the buffer without limit.
|
|
279
|
+
private static let maxHeaderBytes = 64 * 1024
|
|
280
|
+
/// Body streaming chunk — keeps memory flat for large offline files.
|
|
281
|
+
private static let chunkBytes = 256 * 1024
|
|
282
|
+
private static let headerTerminator = Data("\r\n\r\n".utf8)
|
|
283
|
+
|
|
284
|
+
init(
|
|
285
|
+
connection: NWConnection,
|
|
286
|
+
resolve: @escaping (String) -> MediaSource?,
|
|
287
|
+
onFinished: @escaping () -> Void
|
|
288
|
+
) {
|
|
289
|
+
self.connection = connection
|
|
290
|
+
self.resolve = resolve
|
|
291
|
+
self.onFinished = onFinished
|
|
292
|
+
self.queue = DispatchQueue(label: "queueplayer.cast.mediaserver.conn")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func start() {
|
|
296
|
+
connection.stateUpdateHandler = { [weak self] state in
|
|
297
|
+
guard let self = self else { return }
|
|
298
|
+
switch state {
|
|
299
|
+
case .ready:
|
|
300
|
+
self.readRequest(buffer: Data())
|
|
301
|
+
case .failed(let error):
|
|
302
|
+
// A receiver cancelling a range connection it no longer needs
|
|
303
|
+
// arrives here as a reset — expected churn, not an error.
|
|
304
|
+
if !Self.isBenignDisconnect(error) {
|
|
305
|
+
NSLog("[LocalMediaServer] connection failed: \(error)")
|
|
306
|
+
}
|
|
307
|
+
self.cancel()
|
|
308
|
+
case .cancelled:
|
|
309
|
+
self.finish()
|
|
310
|
+
default:
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
connection.start(queue: queue)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Cancel the connection + any in-flight streaming. Idempotent.
|
|
318
|
+
func cancel() {
|
|
319
|
+
stateLock.lock()
|
|
320
|
+
let task = streamTask
|
|
321
|
+
streamTask = nil
|
|
322
|
+
stateLock.unlock()
|
|
323
|
+
task?.cancel()
|
|
324
|
+
connection.cancel()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// MARK: - Request
|
|
328
|
+
|
|
329
|
+
private func readRequest(buffer: Data) {
|
|
330
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) {
|
|
331
|
+
[weak self] data, _, isComplete, error in
|
|
332
|
+
guard let self = self else { return }
|
|
333
|
+
if error != nil {
|
|
334
|
+
self.cancel()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
var buf = buffer
|
|
338
|
+
if let data = data { buf.append(data) }
|
|
339
|
+
// Bound the header accumulation right after the append so a client
|
|
340
|
+
// that never sends `\r\n\r\n` can't grow the buffer past the cap.
|
|
341
|
+
if buf.count > Self.maxHeaderBytes {
|
|
342
|
+
self.respondError(status: 431, reason: "Request Header Fields Too Large")
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
guard let terminator = buf.range(of: Self.headerTerminator) else {
|
|
346
|
+
if isComplete {
|
|
347
|
+
self.cancel()
|
|
348
|
+
} else {
|
|
349
|
+
self.readRequest(buffer: buf)
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
let headerData = buf.subdata(in: buf.startIndex..<terminator.lowerBound)
|
|
354
|
+
guard let request = MediaHTTPRequest(headerData: headerData) else {
|
|
355
|
+
self.respondError(status: 400, reason: "Bad Request")
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
self.serve(request)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private func serve(_ request: MediaHTTPRequest) {
|
|
363
|
+
guard request.method == "GET" || request.method == "HEAD" else {
|
|
364
|
+
respondError(status: 405, reason: "Method Not Allowed")
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
guard let token = request.mediaToken, let source = resolve(token) else {
|
|
368
|
+
respondError(status: 404, reason: "Not Found")
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
switch source {
|
|
372
|
+
case .file(let url, let contentType):
|
|
373
|
+
guard let handle = Self.openForReading(url) else {
|
|
374
|
+
Self.logCache("404 no-file", request: request, detail: url.lastPathComponent)
|
|
375
|
+
respondError(status: 404, reason: "Not Found")
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
Self.logCache("DISK", request: request, detail: "\(handle.size)B \(url.lastPathComponent)")
|
|
379
|
+
serveFile(handle, contentType: contentType, request: request)
|
|
380
|
+
case .readThrough(let src):
|
|
381
|
+
// The cache answers only when it holds the whole file AND the file opens
|
|
382
|
+
// right now — anything else reads through. Opening before committing to a
|
|
383
|
+
// response is what makes that fall-through real: once the head is sent
|
|
384
|
+
// there is no going back to the origin.
|
|
385
|
+
if let cached = src.cachedFile, let handle = Self.openForReading(cached) {
|
|
386
|
+
Self.logCache(
|
|
387
|
+
"HIT", request: request,
|
|
388
|
+
detail: "\(handle.size)B from \(cached.lastPathComponent)")
|
|
389
|
+
serveFile(handle, contentType: MimeTypes.fromFile(cached), request: request)
|
|
390
|
+
} else {
|
|
391
|
+
Self.logCache(
|
|
392
|
+
"MISS", request: request,
|
|
393
|
+
detail: src.cachedFile == nil ? "not cached" : "cache file unopenable")
|
|
394
|
+
readThrough(src, request: request)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/// Open a regular file and measure it, or nil if it is missing, a directory,
|
|
400
|
+
/// or unreadable. One syscall path for both the stat and the open, so the
|
|
401
|
+
/// file cannot vanish between deciding to serve it and reading it.
|
|
402
|
+
private static func openForReading(_ url: URL) -> (handle: FileHandle, size: Int64)? {
|
|
403
|
+
var isDir: ObjCBool = false
|
|
404
|
+
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), !isDir.boolValue,
|
|
405
|
+
let handle = try? FileHandle(forReadingFrom: url),
|
|
406
|
+
let size = try? handle.seekToEnd()
|
|
407
|
+
else { return nil }
|
|
408
|
+
try? handle.seek(toOffset: 0)
|
|
409
|
+
return (handle, Int64(size))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private func serveFile(
|
|
413
|
+
_ opened: (handle: FileHandle, size: Int64),
|
|
414
|
+
contentType: String,
|
|
415
|
+
request: MediaHTTPRequest
|
|
416
|
+
) {
|
|
417
|
+
let size = opened.size
|
|
418
|
+
let isHead = request.method == "HEAD"
|
|
419
|
+
switch Self.resolveRange(request.rangeHeader, fileSize: size) {
|
|
420
|
+
case .full:
|
|
421
|
+
let head = Self.headFull(contentType: contentType, fileSize: size)
|
|
422
|
+
sendBodyAfterHead(head, handle: opened.handle, start: 0, end: max(0, size - 1), include: size > 0 && !isHead)
|
|
423
|
+
case .partial(let start, let end):
|
|
424
|
+
let head = Self.headPartial(contentType: contentType, start: start, end: end, fileSize: size)
|
|
425
|
+
sendBodyAfterHead(head, handle: opened.handle, start: start, end: end, include: !isHead)
|
|
426
|
+
case .unsatisfiable:
|
|
427
|
+
try? opened.handle.close()
|
|
428
|
+
sendHeadOnly(Self.headUnsatisfiable(fileSize: size))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// MARK: - Response
|
|
433
|
+
|
|
434
|
+
private func sendHeadOnly(_ head: String) {
|
|
435
|
+
connection.send(content: Data(head.utf8), completion: .contentProcessed { [weak self] _ in
|
|
436
|
+
self?.cancel()
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/// Send the header block, then (when `include`) stream the inclusive
|
|
441
|
+
/// `[start, end]` byte range from `handle` in bounded chunks. The caller
|
|
442
|
+
/// passes `include: false` for a `HEAD` or an empty file, which sends the
|
|
443
|
+
/// head and closes.
|
|
444
|
+
private func sendBodyAfterHead(_ head: String, handle: FileHandle, start: Int64, end: Int64, include: Bool) {
|
|
445
|
+
let task = Task { [weak self] in
|
|
446
|
+
guard let self = self else { try? handle.close(); return }
|
|
447
|
+
defer { try? handle.close() }
|
|
448
|
+
do {
|
|
449
|
+
try await self.send(Data(head.utf8))
|
|
450
|
+
if include {
|
|
451
|
+
try await self.streamFile(handle, start: start, end: end)
|
|
452
|
+
}
|
|
453
|
+
self.cancel()
|
|
454
|
+
} catch {
|
|
455
|
+
// A client disconnect mid-stream surfaces as a send/read failure —
|
|
456
|
+
// normal churn for cancelled range requests, just clean up. A file
|
|
457
|
+
// unlinked mid-response keeps reading: the handle holds the inode.
|
|
458
|
+
self.cancel()
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
stateLock.lock()
|
|
462
|
+
streamTask = task
|
|
463
|
+
stateLock.unlock()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private func streamFile(_ handle: FileHandle, start: Int64, end: Int64) async throws {
|
|
467
|
+
try handle.seek(toOffset: UInt64(start))
|
|
468
|
+
var remaining = end - start + 1
|
|
469
|
+
while remaining > 0 {
|
|
470
|
+
if Task.isCancelled { return }
|
|
471
|
+
let want = Int(min(Int64(Self.chunkBytes), remaining))
|
|
472
|
+
guard let chunk = try handle.read(upToCount: want), !chunk.isEmpty else { return }
|
|
473
|
+
try await send(chunk)
|
|
474
|
+
remaining -= Int64(chunk.count)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// Relay the origin, applying the consumer's headers and forwarding the
|
|
479
|
+
/// client's `Range` untouched.
|
|
480
|
+
///
|
|
481
|
+
/// The origin's status line is relayed as-is, so an HTTP error stays an HTTP
|
|
482
|
+
/// error and the player classifies it as one. An origin leg that fails before
|
|
483
|
+
/// producing any response closes the connection without a reply, which
|
|
484
|
+
/// AVFoundation reports as a transport failure rather than a server one —
|
|
485
|
+
/// the distinction the retry classifier depends on.
|
|
486
|
+
private func readThrough(_ src: ReadThroughSource, request: MediaHTTPRequest) {
|
|
487
|
+
let task = Task { [weak self] in
|
|
488
|
+
guard let self = self else { return }
|
|
489
|
+
do {
|
|
490
|
+
var upstream = URLRequest(url: src.origin)
|
|
491
|
+
upstream.httpMethod = request.method
|
|
492
|
+
for (name, value) in src.headers {
|
|
493
|
+
upstream.setValue(value, forHTTPHeaderField: name)
|
|
494
|
+
}
|
|
495
|
+
if let range = request.rangeHeader {
|
|
496
|
+
upstream.setValue(range, forHTTPHeaderField: "Range")
|
|
497
|
+
}
|
|
498
|
+
// Identity, always. `URLSession` decodes transparently, so a compressed
|
|
499
|
+
// origin would leave the cache holding decoded bytes while a `Range`
|
|
500
|
+
// addresses the compressed ones — two different byte spaces for one
|
|
501
|
+
// resource, and a cache hit and a miss would disagree about every offset.
|
|
502
|
+
upstream.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
|
503
|
+
|
|
504
|
+
// The body arrives as the `Data` chunks URLSession delivers, not one
|
|
505
|
+
// byte per iteration: this is the playback path when the cache misses.
|
|
506
|
+
let body = OriginBody()
|
|
507
|
+
let dataTask = src.session.dataTask(with: upstream)
|
|
508
|
+
dataTask.delegate = body
|
|
509
|
+
dataTask.resume()
|
|
510
|
+
defer { dataTask.cancel() }
|
|
511
|
+
// The head wait is a plain continuation, so a client hanging up while
|
|
512
|
+
// the origin is still answering has to reach the task through the
|
|
513
|
+
// cancellation handler; the cancel completes the task with an error,
|
|
514
|
+
// which resumes the waiter.
|
|
515
|
+
let response = try await withTaskCancellationHandler {
|
|
516
|
+
try await body.response()
|
|
517
|
+
} onCancel: {
|
|
518
|
+
dataTask.cancel()
|
|
519
|
+
}
|
|
520
|
+
guard let http = response as? HTTPURLResponse else {
|
|
521
|
+
self.cancel()
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
Self.logCache(
|
|
525
|
+
"ORIGIN \(http.statusCode)", request: request,
|
|
526
|
+
detail: "len=\(http.expectedContentLength) "
|
|
527
|
+
+ "range=\(http.value(forHTTPHeaderField: "Content-Range") ?? "-")")
|
|
528
|
+
try await self.send(Data(Self.relayHead(http).utf8))
|
|
529
|
+
if request.method != "HEAD" {
|
|
530
|
+
for try await chunk in body.chunks {
|
|
531
|
+
if Task.isCancelled { break }
|
|
532
|
+
try await self.send(chunk)
|
|
533
|
+
body.consumed(chunk.count, task: dataTask)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
self.cancel()
|
|
537
|
+
} catch {
|
|
538
|
+
Self.logCache("ORIGIN-FAILED", request: request, detail: "\(error)")
|
|
539
|
+
self.cancel()
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
stateLock.lock()
|
|
543
|
+
streamTask = task
|
|
544
|
+
stateLock.unlock()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/// Where every response came from, so "the cache is not being used" is a
|
|
548
|
+
/// thing the log answers rather than a thing to infer from byte totals.
|
|
549
|
+
static func logCache(_ verdict: String, request: MediaHTTPRequest, detail: String) {
|
|
550
|
+
#if DEBUG
|
|
551
|
+
NSLog(
|
|
552
|
+
"[RNQP-CACHE] %@ %@ range=%@ token=%@ %@",
|
|
553
|
+
verdict, request.method, request.rangeHeader ?? "-",
|
|
554
|
+
String((request.mediaToken ?? "-").prefix(8)), detail)
|
|
555
|
+
#endif
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/// Headers relayed from the origin to the client. Everything else is dropped:
|
|
559
|
+
/// the client is AVFoundation reading media, and nothing else the origin says
|
|
560
|
+
/// is its business.
|
|
561
|
+
static let relayedHeaders = ["Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"]
|
|
562
|
+
|
|
563
|
+
/// The reason phrases for the statuses an origin relays. Fixed ASCII rather
|
|
564
|
+
/// than `HTTPURLResponse.localizedString(forStatusCode:)`, which is
|
|
565
|
+
/// localised text; the phrase is optional on the wire, so an unlisted
|
|
566
|
+
/// status is relayed with none.
|
|
567
|
+
static func reasonPhrase(for status: Int) -> String {
|
|
568
|
+
switch status {
|
|
569
|
+
case 200: return "OK"
|
|
570
|
+
case 206: return "Partial Content"
|
|
571
|
+
case 304: return "Not Modified"
|
|
572
|
+
case 400: return "Bad Request"
|
|
573
|
+
case 401: return "Unauthorized"
|
|
574
|
+
case 403: return "Forbidden"
|
|
575
|
+
case 404: return "Not Found"
|
|
576
|
+
case 416: return "Range Not Satisfiable"
|
|
577
|
+
case 500: return "Internal Server Error"
|
|
578
|
+
case 502: return "Bad Gateway"
|
|
579
|
+
case 503: return "Service Unavailable"
|
|
580
|
+
default: return ""
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
static func relayHead(_ response: HTTPURLResponse) -> String {
|
|
585
|
+
var head = "HTTP/1.1 \(response.statusCode) "
|
|
586
|
+
+ "\(reasonPhrase(for: response.statusCode))\r\n"
|
|
587
|
+
for name in relayedHeaders {
|
|
588
|
+
if let value = response.value(forHTTPHeaderField: name) {
|
|
589
|
+
head += "\(name): \(value)\r\n"
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return head + "Connection: close\r\n\r\n"
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private func send(_ data: Data) async throws {
|
|
596
|
+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
597
|
+
connection.send(content: data, completion: .contentProcessed { error in
|
|
598
|
+
if let error = error { cont.resume(throwing: error) } else { cont.resume(returning: ()) }
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private func respondError(status: Int, reason: String) {
|
|
604
|
+
sendHeadOnly(Self.headError(status: status, reason: reason))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private func finish() {
|
|
608
|
+
stateLock.lock()
|
|
609
|
+
if finished { stateLock.unlock(); return }
|
|
610
|
+
finished = true
|
|
611
|
+
let task = streamTask
|
|
612
|
+
streamTask = nil
|
|
613
|
+
stateLock.unlock()
|
|
614
|
+
task?.cancel()
|
|
615
|
+
onFinished()
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// MARK: - Range resolution (pure)
|
|
619
|
+
|
|
620
|
+
/// Resolve an HTTP `Range` header against `fileSize`. Single-range only
|
|
621
|
+
/// (the Cast receiver never sends multi-range); a multi-range header
|
|
622
|
+
/// resolves to its first range. Per RFC 7233 a malformed / inverted
|
|
623
|
+
/// range is ignored (`.full`, i.e. `200`); a syntactically-valid range
|
|
624
|
+
/// starting at or past EOF is `.unsatisfiable` (`416`).
|
|
625
|
+
static func resolveRange(_ header: String?, fileSize: Int64) -> MediaByteRange {
|
|
626
|
+
guard fileSize > 0 else { return .full }
|
|
627
|
+
guard let raw = header?.lowercased(), raw.hasPrefix("bytes=") else { return .full }
|
|
628
|
+
let spec = raw.dropFirst("bytes=".count)
|
|
629
|
+
let firstSpec = spec.split(separator: ",").first.map(String.init) ?? ""
|
|
630
|
+
guard let dash = firstSpec.firstIndex(of: "-") else { return .full }
|
|
631
|
+
let startStr = firstSpec[firstSpec.startIndex..<dash].trimmingCharacters(in: .whitespaces)
|
|
632
|
+
let endStr = firstSpec[firstSpec.index(after: dash)...].trimmingCharacters(in: .whitespaces)
|
|
633
|
+
|
|
634
|
+
if startStr.isEmpty {
|
|
635
|
+
// Suffix form `-N`: the last N bytes.
|
|
636
|
+
guard let suffix = Int64(endStr), suffix > 0 else { return .unsatisfiable }
|
|
637
|
+
let start = max(0, fileSize - suffix)
|
|
638
|
+
return .partial(start: start, end: fileSize - 1)
|
|
639
|
+
}
|
|
640
|
+
guard let start = Int64(startStr), start >= 0 else { return .full }
|
|
641
|
+
if start >= fileSize { return .unsatisfiable }
|
|
642
|
+
if endStr.isEmpty {
|
|
643
|
+
return .partial(start: start, end: fileSize - 1)
|
|
644
|
+
}
|
|
645
|
+
guard let end = Int64(endStr) else { return .full }
|
|
646
|
+
if end < start { return .full }
|
|
647
|
+
return .partial(start: start, end: min(end, fileSize - 1))
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// MARK: - Header builders (pure)
|
|
651
|
+
|
|
652
|
+
static func headFull(contentType: String, fileSize: Int64) -> String {
|
|
653
|
+
"HTTP/1.1 200 OK\r\n"
|
|
654
|
+
+ "Content-Type: \(contentType)\r\n"
|
|
655
|
+
+ "Content-Length: \(fileSize)\r\n"
|
|
656
|
+
+ "Accept-Ranges: bytes\r\n"
|
|
657
|
+
+ "Connection: close\r\n\r\n"
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
static func headPartial(contentType: String, start: Int64, end: Int64, fileSize: Int64) -> String {
|
|
661
|
+
"HTTP/1.1 206 Partial Content\r\n"
|
|
662
|
+
+ "Content-Type: \(contentType)\r\n"
|
|
663
|
+
+ "Content-Range: bytes \(start)-\(end)/\(fileSize)\r\n"
|
|
664
|
+
+ "Content-Length: \(end - start + 1)\r\n"
|
|
665
|
+
+ "Accept-Ranges: bytes\r\n"
|
|
666
|
+
+ "Connection: close\r\n\r\n"
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
static func headUnsatisfiable(fileSize: Int64) -> String {
|
|
670
|
+
"HTTP/1.1 416 Range Not Satisfiable\r\n"
|
|
671
|
+
+ "Content-Range: bytes */\(fileSize)\r\n"
|
|
672
|
+
+ "Content-Length: 0\r\n"
|
|
673
|
+
+ "Connection: close\r\n\r\n"
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
static func headError(status: Int, reason: String) -> String {
|
|
677
|
+
"HTTP/1.1 \(status) \(reason)\r\n"
|
|
678
|
+
+ "Content-Length: 0\r\n"
|
|
679
|
+
+ "Connection: close\r\n\r\n"
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// MARK: - Helpers
|
|
683
|
+
|
|
684
|
+
private static func isBenignDisconnect(_ error: NWError) -> Bool {
|
|
685
|
+
if case .posix(let code) = error {
|
|
686
|
+
return code == .ECONNRESET || code == .ENOTCONN || code == .EPIPE || code == .ECANCELED
|
|
687
|
+
}
|
|
688
|
+
return false
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|