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,1042 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
|
|
3
|
+
extension TrackPlayer {
|
|
4
|
+
// MARK: - Events — dispatch
|
|
5
|
+
|
|
6
|
+
/// Called from the `\.currentItem` KVO dispatch in
|
|
7
|
+
/// `installGaplessObservers`. Fires `onTrackChange` whenever the
|
|
8
|
+
/// player's current item changes, and — when the change is
|
|
9
|
+
/// "currentItem went nil while the queue still had tracks" outside
|
|
10
|
+
/// of an in-flight mutation — also fires `onQueueEnd` and emits an
|
|
11
|
+
/// `.ended` state change.
|
|
12
|
+
///
|
|
13
|
+
/// Mid-mutation nil fires are suppressed entirely. Every
|
|
14
|
+
/// `removeAllItems` + `insert` pair produces a transient
|
|
15
|
+
/// `currentItem == nil` observation before the real new item
|
|
16
|
+
/// lands; without the `isMutatingPlayerQueue` guard we'd both
|
|
17
|
+
/// consume `pendingTrackChangeReason` against the intermediate
|
|
18
|
+
/// fire AND spuriously emit `onQueueEnd` on every backward skip,
|
|
19
|
+
/// setQueue, full rebuild, etc.
|
|
20
|
+
internal func handleCurrentItemDidChange() {
|
|
21
|
+
if self.isMutatingPlayerQueue && self.player?.currentItem == nil {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
// Snapshot timestamp first thing so `transitionGapMs` measures the
|
|
25
|
+
// iOS-side notification→KVO scheduling delta, not the synchronous
|
|
26
|
+
// lib work that follows. Always overwritten — cheap UInt64 store.
|
|
27
|
+
self.gaplessGapMeasuredAtNs = DispatchTime.now().uptimeNanoseconds
|
|
28
|
+
let reason = consumePendingReasonAndResyncIndex()
|
|
29
|
+
dispatchTrackChange(reason: reason)
|
|
30
|
+
// Native auto-advance can change `currentTrackIndex` (via the
|
|
31
|
+
// matchTrackIndex resync) without going through any explicit
|
|
32
|
+
// mutation method. Recompute capabilities here so the boundary-
|
|
33
|
+
// flip canSkipNext under `.off` (last-track auto-advance leaving
|
|
34
|
+
// cur on the last track with no next) lands.
|
|
35
|
+
self.recomputeCapabilities()
|
|
36
|
+
detectQueueEnd()
|
|
37
|
+
// The matchTrackIndex resync makes `currentTrackIndex` canonical
|
|
38
|
+
// post-auto-advance, so rescheduling the prefetcher window from
|
|
39
|
+
// here shifts it to cur+1..cur+lookaheadCount against the
|
|
40
|
+
// freshly-synced index. The prefetcher dedupes by url, so
|
|
41
|
+
// re-issuing the same window mid-prefetch is cheap.
|
|
42
|
+
self.rescheduleLookahead()
|
|
43
|
+
// Last: the append resolves against the freshly-pointed prefetcher, and
|
|
44
|
+
// its synchronous item construction stays behind the user-visible
|
|
45
|
+
// track change rather than in front of it.
|
|
46
|
+
self.topUpWindow()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Consume `pendingTrackChangeReason` + `didPlayToEndPending` and,
|
|
50
|
+
/// when the player has auto-advanced naturally, resync
|
|
51
|
+
/// `currentTrackIndex` from the player's current AVPlayerItem via
|
|
52
|
+
/// `matchTrackIndex`. Returns the consumed reason for the caller
|
|
53
|
+
/// to thread into `dispatchTrackChange`. Defaults to `.autoAdvance`
|
|
54
|
+
/// when no user mutation stashed a reason.
|
|
55
|
+
///
|
|
56
|
+
/// Resync requires BOTH `didPlayToEndPending` set AND no pending
|
|
57
|
+
/// user-mutation reason. User mutations pre-write
|
|
58
|
+
/// `currentTrackIndex` before the rebuild — that intent is
|
|
59
|
+
/// authoritative. The reason gate covers the FB9221518 case where
|
|
60
|
+
/// a paused-state `removeAllItems` fires DidPlayToEndTime for a
|
|
61
|
+
/// queue item that was never played; without it the post-rebuild
|
|
62
|
+
/// `\.currentItem` KVO, once on `playerQueue`, would resync to
|
|
63
|
+
/// AVQueuePlayer's stale `currentItem` value (the pre-rebuild item).
|
|
64
|
+
private func consumePendingReasonAndResyncIndex() -> TrackChangeReason {
|
|
65
|
+
// Resync gate: only when the player auto-advanced naturally
|
|
66
|
+
// (no pending user-mutation reason AND DidPlayToEndTime fired).
|
|
67
|
+
//
|
|
68
|
+
// Two gates AND-ed together because each catches a distinct
|
|
69
|
+
// wrong-resync vector:
|
|
70
|
+
//
|
|
71
|
+
// 1. `didPlayToEndPending`: chain-advance bounces during a
|
|
72
|
+
// `removeAllItems` + insert rebuild do NOT fire the notification,
|
|
73
|
+
// so this gate excludes those.
|
|
74
|
+
// 2. `pendingUserMutation`: a paused-state queue mutation
|
|
75
|
+
// (skipToIndex, skipPrev, skipNext via fullRebuild) under FB9221518
|
|
76
|
+
// can fire `AVPlayerItemDidPlayToEndTime` for an item being
|
|
77
|
+
// pulled from the queue. Without this gate the fired
|
|
78
|
+
// notification lands at the post-mutation `\.currentItem` KVO
|
|
79
|
+
// hop to `playerQueue`, sees `didPlayToEndPending == true`, and resyncs
|
|
80
|
+
// `currentTrackIndex` to whatever AVQueuePlayer's
|
|
81
|
+
// not-yet-published `currentItem` resolves to — which can be
|
|
82
|
+
// the pre-mutation item's queueItemId. The user-mutation pre-
|
|
83
|
+
// write of `currentTrackIndex` is authoritative; the resync
|
|
84
|
+
// must defer to it.
|
|
85
|
+
let pendingUserMutation = self.pendingTrackChangeReason != nil
|
|
86
|
+
let reason = self.pendingTrackChangeReason ?? .autoAdvance
|
|
87
|
+
self.pendingTrackChangeReason = nil
|
|
88
|
+
let endFired = self.didPlayToEndPending
|
|
89
|
+
self.didPlayToEndPending = false
|
|
90
|
+
// A queue stopped on a reported failure does not re-derive its index from
|
|
91
|
+
// the player. `removeAllItems` fires `AVPlayerItemDidPlayToEndTime` for an
|
|
92
|
+
// item that never played (FB9221518), and the re-seat below issues one, so
|
|
93
|
+
// without this a later pass would resync off the parked position onto the
|
|
94
|
+
// track behind it — the move the stop exists to prevent. The report does
|
|
95
|
+
// not exist yet on the pass that files it, so that pass still runs.
|
|
96
|
+
if endFired && !pendingUserMutation && self.reportedFailureOnCurrentTrack == nil {
|
|
97
|
+
if let derived = matchTrackIndex(forCurrentItem: self.player?.currentItem) {
|
|
98
|
+
// One natural advance that lands more than one position on means the
|
|
99
|
+
// player dropped what it skipped: `AVQueuePlayer` discards an item that
|
|
100
|
+
// cannot be played, and a track that failed while the previous one was
|
|
101
|
+
// still playing is never seated, so nothing ever observes its failure —
|
|
102
|
+
// the current-item status observer is the only load-failure path and it
|
|
103
|
+
// never sees that item. Carrying the queue past it would skip a track
|
|
104
|
+
// the listener never heard and was never told about.
|
|
105
|
+
if derived > self.currentTrackIndex + 1 {
|
|
106
|
+
self.stopOnDroppedTrack(at: self.currentTrackIndex + 1)
|
|
107
|
+
} else {
|
|
108
|
+
self.currentTrackIndex = derived
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return reason
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/// Stop on a track the player dropped mid-advance, report it, and park.
|
|
117
|
+
///
|
|
118
|
+
/// Nothing has observed this track fail — `AVQueuePlayer` removed it rather
|
|
119
|
+
/// than seating it — so the report is raised here, against the position.
|
|
120
|
+
/// `fileFormatNotRecognized` is chosen for being fatal, which is what the
|
|
121
|
+
/// stop needs: the same function drops the play intent, and doing that under
|
|
122
|
+
/// a transient code would contradict `dispatchErrorOrRetry`, which refuses to.
|
|
123
|
+
/// The library does not know *why* the player dropped it — a 404, a DNS
|
|
124
|
+
/// failure and a non-media body all land here — so the code is a statement
|
|
125
|
+
/// about recoverability, not about the body.
|
|
126
|
+
private func stopOnDroppedTrack(at index: Int) {
|
|
127
|
+
guard let qid = self.queueItemIds[safe: index] else { return }
|
|
128
|
+
self.currentTrackIndex = index
|
|
129
|
+
self.engine?.pause()
|
|
130
|
+
self.wantsToPlay = false
|
|
131
|
+
// The player discarded this position's item and is sitting on one further
|
|
132
|
+
// on, so every read that answers from the seated item — duration,
|
|
133
|
+
// seekability, the progress payload's position — would describe a track the
|
|
134
|
+
// listener is not on. Re-seat the failed position so the index and the item
|
|
135
|
+
// agree. Forced, because the item keeps its `queueItemId` and an identity
|
|
136
|
+
// comparison would find the slice already correct.
|
|
137
|
+
//
|
|
138
|
+
// Read before the rebuild, which empties the set: a track the status
|
|
139
|
+
// observer already reported is one the consumer has been told about, and
|
|
140
|
+
// the drop is the same failure arriving by a second route.
|
|
141
|
+
let alreadyReported = self.reportedFailureQueueItemIds.contains(qid)
|
|
142
|
+
// The report is filed after: a forced rebuild clears the set, and the
|
|
143
|
+
// re-seated item failing again is then suppressed by this entry, so one
|
|
144
|
+
// dropped track still reports once.
|
|
145
|
+
self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
|
|
146
|
+
self.reportedFailureQueueItemIds.insert(qid)
|
|
147
|
+
// The queue still parks — index, pause, intent and the re-seat above all
|
|
148
|
+
// stand — it just does not say so twice.
|
|
149
|
+
if alreadyReported { return }
|
|
150
|
+
let err = self.buildPlaybackError(
|
|
151
|
+
nil,
|
|
152
|
+
mapped: .fileFormatNotRecognized,
|
|
153
|
+
queueItemId: qid,
|
|
154
|
+
url: self.tracks[safe: index]?.url ?? "",
|
|
155
|
+
nativeDomainOverride: "PLAYER_ITEM_DROPPED"
|
|
156
|
+
)
|
|
157
|
+
self.errorListeners.forEach { $0(err) }
|
|
158
|
+
self.emitState(.error, reason: .error)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Debug-only log of the classifier's verdict for the current item next to
|
|
162
|
+
/// the URL its asset is bound to and its `sourceURL`.
|
|
163
|
+
///
|
|
164
|
+
/// The verdict is `classifyTrackSource`'s, computed by the caller from the
|
|
165
|
+
/// track URL, whether the item is routed through the read-through server,
|
|
166
|
+
/// and whether the cache holds the whole file. The asset URL decides
|
|
167
|
+
/// nothing: for a routed item it is the server's `127.0.0.1` address
|
|
168
|
+
/// whether the bytes come from disk or the origin. Queries are stripped: a
|
|
169
|
+
/// source URL carries a token in its query for any server that
|
|
170
|
+
/// authenticates that way.
|
|
171
|
+
private func logPlaybackSource(_ classified: TrackSource?) {
|
|
172
|
+
#if DEBUG
|
|
173
|
+
guard let item = self.engine?.currentMediaItem else { return }
|
|
174
|
+
let assetURL = (item.asset as? AVURLAsset)?.url.absoluteString ?? "<non-url asset>"
|
|
175
|
+
let source = item.sourceURL ?? "<none>"
|
|
176
|
+
let origin = classified.map { "\($0)" } ?? "unknown"
|
|
177
|
+
NSLog(
|
|
178
|
+
"[RNQP-DIAG] now-playing from=%@ asset=%@ source=%@",
|
|
179
|
+
origin,
|
|
180
|
+
PlaybackErrorMapping.stripQuery(assetURL),
|
|
181
|
+
PlaybackErrorMapping.stripQuery(source)
|
|
182
|
+
)
|
|
183
|
+
#endif
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Re-derive `currentTrackSource` for whatever `currentTrackIndex` now
|
|
187
|
+
/// points at. Caller is on `playerQueue`.
|
|
188
|
+
internal func reclassifyCurrentTrackSource() {
|
|
189
|
+
let idx = self.currentTrackIndex
|
|
190
|
+
let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
|
|
191
|
+
? self.tracks[idx] : nil
|
|
192
|
+
let cache = self.lookaheadCache
|
|
193
|
+
// Whether this position's item reads through the server is decided when
|
|
194
|
+
// the item is built and stamped on it, so the installed item is asked
|
|
195
|
+
// first — any item in the run, not only the seated one, so the answer
|
|
196
|
+
// does not depend on the engine having advanced yet. A position with no
|
|
197
|
+
// item installed is answered on the terms `makeItem` will use for it.
|
|
198
|
+
// A disabled cache serves nothing, so nothing is `cached` while it is off.
|
|
199
|
+
let stamped: Bool? = engineIndex(forQueuePosition: idx).flatMap { engineIdx in
|
|
200
|
+
self.engine?.allMediaItems[safe: engineIdx]?.isRoutedThroughReadThroughServer
|
|
201
|
+
}
|
|
202
|
+
let isRouted = self.lookaheadConfig.enabled
|
|
203
|
+
&& (stamped ?? (self.routingServer != nil
|
|
204
|
+
&& (track.map { AVQueueBuilder.isRoutable($0.url) } ?? false)))
|
|
205
|
+
self.currentTrackSource = TrackPlayer.classifyTrackSource(
|
|
206
|
+
url: track?.url, isRouted: isRouted
|
|
207
|
+
) { url in
|
|
208
|
+
cache?.isFullyCached(url: url) ?? false
|
|
209
|
+
}
|
|
210
|
+
logPlaybackSource(self.currentTrackSource)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Fire `trackChangeListeners` for the current `(track, idx,
|
|
214
|
+
/// reason)`, deduped on `lastEmittedQueueItemId` (NOT idx — index
|
|
215
|
+
/// alone would suppress setQueue replacements that keep cur=0 with
|
|
216
|
+
/// a different first track). On a real fire, also resets the
|
|
217
|
+
/// error-dedup state so a new item's identical-coded error fires.
|
|
218
|
+
internal func dispatchTrackChange(reason: TrackChangeReason) {
|
|
219
|
+
let idx = self.currentTrackIndex
|
|
220
|
+
let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
|
|
221
|
+
? self.tracks[idx] : nil
|
|
222
|
+
let currentItemId: String? = self.queueItemIds[safe: idx]
|
|
223
|
+
self.reclassifyCurrentTrackSource()
|
|
224
|
+
if self.lastEmittedQueueItemId != currentItemId {
|
|
225
|
+
self.lastEmittedQueueItemId = currentItemId
|
|
226
|
+
self.lastErrorQueueItemId = nil
|
|
227
|
+
self.lastErrorCode = nil
|
|
228
|
+
// A report against some OTHER track says nothing about the one now being
|
|
229
|
+
// announced, so it goes. A report against the track being announced
|
|
230
|
+
// stands: an item that is already failed when it is seated has its error
|
|
231
|
+
// dispatched before this, the first announcement of its position, and
|
|
232
|
+
// clearing here would leave the queue with nothing to hold on.
|
|
233
|
+
self.reportedFailureQueueItemIds = self.reportedFailureQueueItemIds
|
|
234
|
+
.intersection(currentItemId.map { [$0] } ?? [])
|
|
235
|
+
// A real track (re)loaded => no longer at the end of the queue. At true
|
|
236
|
+
// end-of-queue this block is skipped entirely — the last-track id still
|
|
237
|
+
// matches `lastEmittedQueueItemId` — so `reachedQueueEnd` stays set; the
|
|
238
|
+
// `idx >= 0` guard only keeps an explicit clear to index -1 from touching it.
|
|
239
|
+
if idx >= 0 { self.reachedQueueEnd = false }
|
|
240
|
+
// New active track => new milestone playthrough. This is the single
|
|
241
|
+
// deduped track-change point (the crossfade fade-start echo collapses
|
|
242
|
+
// here), so it resets exactly once per real track change.
|
|
243
|
+
self.milestoneTracker.reset()
|
|
244
|
+
// New track => its initial load counts as `buffering` (not `stalled`)
|
|
245
|
+
// until it reaches a playing state, and it starts not-fully-buffered.
|
|
246
|
+
self.hasStartedPlaying = false
|
|
247
|
+
self.recomputeFullyBuffered()
|
|
248
|
+
// Measure the gapless auto-advance silence gap (nil for every
|
|
249
|
+
// other transition) before the emit so it rides on onTrackChange
|
|
250
|
+
// as `transitionGapMs`. The timestamp was captured at the top of
|
|
251
|
+
// handleCurrentItemDidChange, above this method's synchronous lib
|
|
252
|
+
// work; falls back to now() if the snapshot wasn't taken.
|
|
253
|
+
var transitionGapMs: Double? = nil
|
|
254
|
+
if reason == .autoAdvance && self.gaplessGapPrevEndedAtNs > 0 {
|
|
255
|
+
let now = self.gaplessGapMeasuredAtNs > 0
|
|
256
|
+
? self.gaplessGapMeasuredAtNs
|
|
257
|
+
: DispatchTime.now().uptimeNanoseconds
|
|
258
|
+
let gapNs = now &- self.gaplessGapPrevEndedAtNs
|
|
259
|
+
transitionGapMs = Double(gapNs) / 1_000_000.0
|
|
260
|
+
self.gaplessGapPrevEndedAtNs = 0
|
|
261
|
+
self.gaplessGapMeasuredAtNs = 0
|
|
262
|
+
#if DEBUG
|
|
263
|
+
self.gaplessLogLastChangedAtNs = now
|
|
264
|
+
NSLog("[RNQP-GAPLESS] item-changed trackIdx=%d ts_ns=%llu delta_since_ended_ms=%.3f",
|
|
265
|
+
idx, now, transitionGapMs ?? 0)
|
|
266
|
+
#endif
|
|
267
|
+
}
|
|
268
|
+
// Cross-platform listener-fire order: track-change first so the
|
|
269
|
+
// consumer's track-keyed handlers run before the format-refresh
|
|
270
|
+
// emits null. Matches Android's onMediaItemTransition →
|
|
271
|
+
// refreshNowPlayingFormatForActiveItem ordering.
|
|
272
|
+
trackChangeListeners.forEach { $0(track, Double(idx), reason, transitionGapMs) }
|
|
273
|
+
self.refreshNowPlayingFormatForActiveItem()
|
|
274
|
+
self.nowPlayingInfo.refreshAll(self.nowPlayingSnapshot())
|
|
275
|
+
// Kick the network resolve. The placeholder is already on the
|
|
276
|
+
// lock-screen via refreshAll; the resolver swaps in the real
|
|
277
|
+
// bitmap when the fetch lands. ArtworkResolver's single in-
|
|
278
|
+
// flight + last-URL guard drops a stale fetch if another track
|
|
279
|
+
// change fires before completion.
|
|
280
|
+
self.artworkResolver.resolve(url: track?.artworkUrl) { [weak self] image in
|
|
281
|
+
self?.nowPlayingInfo.applyArtwork(image)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Classify the active-track playback source.
|
|
287
|
+
///
|
|
288
|
+
/// `.cached` means the bytes being played are coming off local disk, which
|
|
289
|
+
/// takes two things: the cache holds the track in full, **and** the item is
|
|
290
|
+
/// bound to the read-through server, which is what reads from the cache. A
|
|
291
|
+
/// track sitting complete on disk that the player is not routed through is
|
|
292
|
+
/// still being streamed, and saying otherwise is how this reported `cached`
|
|
293
|
+
/// while every byte came from the origin.
|
|
294
|
+
///
|
|
295
|
+
/// `nil` when the URL is nil or its scheme is neither `file://` nor
|
|
296
|
+
/// `http(s)://` — `data:`, `blob:` and custom schemes have no contract here.
|
|
297
|
+
internal static func classifyTrackSource(
|
|
298
|
+
url: String?, isRouted: Bool, isUrlCached: (String) -> Bool
|
|
299
|
+
) -> TrackSource? {
|
|
300
|
+
guard let url = url else { return nil }
|
|
301
|
+
if url.hasPrefix("file://") { return .local }
|
|
302
|
+
let isHttp = url.hasPrefix("http://") || url.hasPrefix("https://")
|
|
303
|
+
guard isHttp else { return nil }
|
|
304
|
+
guard isRouted else { return .streaming }
|
|
305
|
+
return isUrlCached(url) ? .cached : .streaming
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Fires from the `addPeriodicTimeObserver` tick (gapless mode)
|
|
309
|
+
/// or the engine-agnostic `progressFallbackTimer` (crossfade
|
|
310
|
+
/// mode, where `self.player` is nil). Reports current position +
|
|
311
|
+
/// duration + seconds-buffered-ahead-of-position.
|
|
312
|
+
///
|
|
313
|
+
/// The two paths source position differently: gapless reads off
|
|
314
|
+
/// `self.player.currentItem` directly; crossfade reads off
|
|
315
|
+
/// `engine.currentPositionSeconds` / `currentDurationSeconds` /
|
|
316
|
+
/// `bufferedPositionSeconds` (computed properties on the
|
|
317
|
+
/// engine surface that delegate to whichever leg is leading).
|
|
318
|
+
internal func emitProgressIfSubscribed() {
|
|
319
|
+
guard !progressListeners.isEmpty else { return }
|
|
320
|
+
let position: TimeInterval
|
|
321
|
+
let duration: TimeInterval
|
|
322
|
+
let buffered: TimeInterval
|
|
323
|
+
// Whether the playhead is actively advancing. The throttle applies only
|
|
324
|
+
// while playing (the streaming firehose); a paused player's ticks are
|
|
325
|
+
// discrete (seek / stop / repeat-rewind) and must always deliver so the UI
|
|
326
|
+
// reflects the move — otherwise a paused seek under a >500ms interval is
|
|
327
|
+
// lost until playback resumes.
|
|
328
|
+
let isPlaying: Bool
|
|
329
|
+
if let item = self.player?.currentItem {
|
|
330
|
+
position = CMTimeGetSeconds(item.currentTime())
|
|
331
|
+
let rawDuration = CMTimeGetSeconds(item.duration)
|
|
332
|
+
duration = (rawDuration.isFinite && rawDuration > 0) ? rawDuration : 0.0
|
|
333
|
+
buffered = loadedAheadSeconds(for: item, currentPosition: position)
|
|
334
|
+
isPlaying = (self.player?.rate ?? 0) != 0
|
|
335
|
+
} else if let engine = self.engine {
|
|
336
|
+
// Engine-driven path (CrossfadeEngine): the fallback timer fires
|
|
337
|
+
// unconditionally. While paused, emit ONLY when the playhead moved since
|
|
338
|
+
// the last emit (a seek / stop / repeat-rewind), mirroring the gapless
|
|
339
|
+
// periodic observer which fires once on seek completion even while
|
|
340
|
+
// paused — otherwise the post-stop "position reset to 0" tick never
|
|
341
|
+
// reaches consumers. A steady pause (position unchanged) still skips, so
|
|
342
|
+
// identical tuples aren't re-published every 0.5s.
|
|
343
|
+
position = engine.currentPositionSeconds
|
|
344
|
+
// Qualify Swift.abs: a bare `abs(...)` is ambiguous against Darwin's
|
|
345
|
+
// C `abs` when that's in scope (the library build context), so name
|
|
346
|
+
// the Swift overload explicitly — matches BiquadCoefficients.
|
|
347
|
+
let playheadDelta = Swift.abs(position - self.lastEmittedCrossfadePosition)
|
|
348
|
+
let moved = playheadDelta > 0.001
|
|
349
|
+
guard engine.isPlaying || moved else { return }
|
|
350
|
+
self.lastEmittedCrossfadePosition = position
|
|
351
|
+
duration = engine.currentDurationSeconds
|
|
352
|
+
let bufferedAbs = engine.bufferedPositionSeconds
|
|
353
|
+
buffered = max(0, bufferedAbs - position)
|
|
354
|
+
isPlaying = engine.isPlaying
|
|
355
|
+
} else {
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
let safePosition = position.isFinite ? position : 0.0
|
|
359
|
+
let safeDuration = duration.isFinite ? duration : 0.0
|
|
360
|
+
let progress = PlayerProgress(
|
|
361
|
+
position: safePosition, duration: safeDuration, buffered: buffered)
|
|
362
|
+
// Throttle only the playing firehose; paused ticks (discrete seek/stop
|
|
363
|
+
// results) always deliver. The crossfade movement bookkeeping above already
|
|
364
|
+
// ran this tick, and milestones + buffer state are separate calls, so both
|
|
365
|
+
// stay full-rate regardless of the throttle.
|
|
366
|
+
guard !isPlaying || self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else {
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
progressListeners.forEach { $0(progress) }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Advance the milestone tracker off the same periodic tick and emit any
|
|
373
|
+
/// 25/50/75/90% thresholds forward playback just crossed. Runs the
|
|
374
|
+
/// tracker even with no subscribers (so a mid-stream subscribe doesn't
|
|
375
|
+
/// retroactively fire already-passed thresholds); emission is gated on
|
|
376
|
+
/// having listeners.
|
|
377
|
+
internal func processMilestones() {
|
|
378
|
+
// No active track => nothing to attribute a milestone to (mirrors the
|
|
379
|
+
// Android guard; avoids emitting with trackIndex -1 on a stale read
|
|
380
|
+
// during a clear / queue-end transition).
|
|
381
|
+
guard !self.tracks.isEmpty, self.currentTrackIndex >= 0 else { return }
|
|
382
|
+
let position: TimeInterval
|
|
383
|
+
let playerDuration: TimeInterval
|
|
384
|
+
if let item = self.player?.currentItem {
|
|
385
|
+
position = CMTimeGetSeconds(item.currentTime())
|
|
386
|
+
let raw = CMTimeGetSeconds(item.duration)
|
|
387
|
+
playerDuration = (raw.isFinite && raw > 0) ? raw : 0
|
|
388
|
+
} else if let engine = self.engine {
|
|
389
|
+
position = engine.currentPositionSeconds
|
|
390
|
+
playerDuration = engine.currentDurationSeconds
|
|
391
|
+
} else {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
guard position.isFinite else { return }
|
|
395
|
+
let duration = effectiveMilestoneDuration(playerDuration: playerDuration)
|
|
396
|
+
let crossed = milestoneTracker.tick(durationSec: duration, positionSec: position)
|
|
397
|
+
guard !crossed.isEmpty, !milestoneListeners.isEmpty else { return }
|
|
398
|
+
let idx = Double(self.currentTrackIndex)
|
|
399
|
+
for m in crossed {
|
|
400
|
+
milestoneListeners.forEach { $0(Double(m), idx) }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Player-reported duration when known (> 0), else the consumer-supplied
|
|
405
|
+
/// `TrackItem.duration` (seconds) for the active track, else 0 (no
|
|
406
|
+
/// computable duration → no milestones).
|
|
407
|
+
internal func effectiveMilestoneDuration(playerDuration: TimeInterval) -> TimeInterval {
|
|
408
|
+
if playerDuration > 0 { return playerDuration }
|
|
409
|
+
let idx = self.currentTrackIndex
|
|
410
|
+
if idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 {
|
|
411
|
+
return d
|
|
412
|
+
}
|
|
413
|
+
return 0
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// Map an `AVPlayerItem` back to an index in `self.tracks`.
|
|
417
|
+
///
|
|
418
|
+
/// Lookup is keyed on the lib-generated `queueItemId`
|
|
419
|
+
/// associated-object set by `AVQueueBuilder.makeItem` at
|
|
420
|
+
/// construction. `self.queueItemIds` is a parallel `[String]`
|
|
421
|
+
/// array maintained 1:1 with `self.tracks` at every queue
|
|
422
|
+
/// mutation site; `firstIndex(of:)` resolves the AVPlayerItem's
|
|
423
|
+
/// `queueItemId` to its position in O(N) — N is queue size,
|
|
424
|
+
/// typical < 100 tracks, no memoization warranted.
|
|
425
|
+
///
|
|
426
|
+
/// queueItemId is unique per construction, so each AVPlayerItem
|
|
427
|
+
/// maps to exactly one queue position even when the consumer
|
|
428
|
+
/// supplies the same `track.url` at multiple positions.
|
|
429
|
+
///
|
|
430
|
+
/// Returns nil when `item` is nil, when the item lacks a
|
|
431
|
+
/// queueItemId (e.g. constructed outside `AVQueueBuilder` —
|
|
432
|
+
/// shouldn't happen in production), or when the queueItemId
|
|
433
|
+
/// isn't in `self.queueItemIds` (post-mutation desync — also
|
|
434
|
+
/// shouldn't happen, but defensive). The caller leaves
|
|
435
|
+
/// `currentTrackIndex` unchanged on a nil result.
|
|
436
|
+
internal func matchTrackIndex(forCurrentItem item: AVPlayerItem?) -> Int? {
|
|
437
|
+
guard let id = item?.queueItemId else { return nil }
|
|
438
|
+
return self.queueItemIds.firstIndex(of: id)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/// Translate a queue position into the active engine's own index space by
|
|
442
|
+
/// item identity. An engine indexes within the slice it was handed, so a
|
|
443
|
+
/// queue position is only usable after this lookup.
|
|
444
|
+
///
|
|
445
|
+
/// `nil` means the track is not materialised in the engine right now. That
|
|
446
|
+
/// is a rebuild signal, not an error — the caller reinstalls the queue at
|
|
447
|
+
/// the wanted position rather than issuing a seek the engine would reject.
|
|
448
|
+
internal func engineIndex(forQueuePosition position: Int) -> Int? {
|
|
449
|
+
guard let engine = self.engine,
|
|
450
|
+
position >= 0, position < self.queueItemIds.count else { return nil }
|
|
451
|
+
let id = self.queueItemIds[position]
|
|
452
|
+
return engine.allMediaItems.firstIndex { $0.queueItemId == id }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/// Compute "seconds buffered ahead of position" — consumers use
|
|
456
|
+
/// this for the "buffered track" overlay on a progress bar. Takes
|
|
457
|
+
/// the end of the last loaded `CMTimeRange`, subtracts the current
|
|
458
|
+
/// position, and clamps to zero. Returns 0 when no ranges are
|
|
459
|
+
/// loaded or when the subtraction is non-finite.
|
|
460
|
+
private func loadedAheadSeconds(
|
|
461
|
+
for item: AVPlayerItem, currentPosition: Double
|
|
462
|
+
) -> Double {
|
|
463
|
+
guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return 0 }
|
|
464
|
+
let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
|
|
465
|
+
guard end.isFinite, currentPosition.isFinite else { return 0 }
|
|
466
|
+
return max(0, end - currentPosition)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/// Arm stall recovery. AVPlayer reports the underrun and then does nothing:
|
|
470
|
+
/// with `automaticallyWaitsToMinimizeStalling` off it will not resume when
|
|
471
|
+
/// the buffer refills, so `recomputeBufferState` re-issues play once it does.
|
|
472
|
+
///
|
|
473
|
+
/// Gated on transport intent — a stall that arrives while the user is paused
|
|
474
|
+
/// must not arm a resume that fires later and starts audio they did not ask
|
|
475
|
+
/// for.
|
|
476
|
+
@objc internal func handlePlaybackStalled(_ notification: Notification) {
|
|
477
|
+
// The observer is registered for every item in the process; only the
|
|
478
|
+
// item being heard can stall playback. A crossfade standby leg stalling
|
|
479
|
+
// in preroll, or another player's item, must not arm a resume.
|
|
480
|
+
let stalled = notification.object as? AVPlayerItem
|
|
481
|
+
playerQueue.async {
|
|
482
|
+
guard self.wantsToPlay, let stalled, stalled === self.activeMediaItem else { return }
|
|
483
|
+
self.isRecoveringFromStall = true
|
|
484
|
+
// The buffer can refill before this notification is delivered. The
|
|
485
|
+
// keep-up flag only reports on change, so waiting for the next edge
|
|
486
|
+
// would wait forever — recompute now and let it settle the resume.
|
|
487
|
+
self.recomputeBufferState()
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/// Recompute the current buffer state from the active engine item, advance
|
|
492
|
+
/// the per-track buffered high-water mark, and fire `onBufferStateChange`
|
|
493
|
+
/// when the discrete state actually changes. Runs on the buffer KVO, each
|
|
494
|
+
/// progress tick, the transport methods, and at track change.
|
|
495
|
+
///
|
|
496
|
+
/// `.full` = keeps up (`playbackLikelyToKeepUp` / `playbackBufferFull`).
|
|
497
|
+
/// A not-full loaded item is `.stalled` when playback had started and is
|
|
498
|
+
/// intended (a mid-play rebuffer) or `.buffering` otherwise (initial load /
|
|
499
|
+
/// loading while paused). `.empty` means nothing is loaded to play.
|
|
500
|
+
internal func recomputeBufferState() {
|
|
501
|
+
let item = self.engine?.currentMediaItem
|
|
502
|
+
// A newly seated item announces its buffer state fresh. Drop the emitted
|
|
503
|
+
// baseline when the active item's queueItemId changes so the first recompute
|
|
504
|
+
// for the new item emits even if it lands on the same discrete state the
|
|
505
|
+
// previous item ended on — a `.full` queue reloaded to another `.full`
|
|
506
|
+
// local track otherwise catches no intermediate sample and emits nothing.
|
|
507
|
+
let itemId = item?.queueItemId
|
|
508
|
+
if itemId != self.lastBufferStateQueueItemId {
|
|
509
|
+
self.lastBufferStateQueueItemId = itemId
|
|
510
|
+
self.lastEmittedBufferState = nil
|
|
511
|
+
}
|
|
512
|
+
let computed: BufferState
|
|
513
|
+
if let item {
|
|
514
|
+
if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
|
|
515
|
+
computed = .full
|
|
516
|
+
} else {
|
|
517
|
+
computed = (self.hasStartedPlaying && self.wantsToPlay) ? .stalled : .buffering
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
computed = .empty
|
|
521
|
+
}
|
|
522
|
+
self.currentBufferState = computed
|
|
523
|
+
// The buffer has recovered and the user still wants playback: nothing else
|
|
524
|
+
// will restart it. Not while the session is interrupted — the refill can
|
|
525
|
+
// land mid-call, and the interruption's `.ended` is what resumes then.
|
|
526
|
+
// Not while the queue is stopped on a reported failure either: the player
|
|
527
|
+
// has already moved onto the track behind it, and a refill is not the
|
|
528
|
+
// listener asking for that track.
|
|
529
|
+
if self.isRecoveringFromStall, computed == .full, self.wantsToPlay,
|
|
530
|
+
!self.isInterrupted, self.reportedFailureOnCurrentTrack == nil {
|
|
531
|
+
self.isRecoveringFromStall = false
|
|
532
|
+
self.beginPlayback()
|
|
533
|
+
}
|
|
534
|
+
guard computed != self.lastEmittedBufferState else { return }
|
|
535
|
+
self.lastEmittedBufferState = computed
|
|
536
|
+
bufferStateListeners.forEach { $0(computed) }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/// The whole track has downloaded — the loaded range has reached the track's
|
|
540
|
+
/// duration. When the player can't report a duration (e.g. a transcoded
|
|
541
|
+
/// stream) it falls back to the consumer-supplied `track.duration`, the same
|
|
542
|
+
/// fallback milestones use. A live / indefinite source never completes.
|
|
543
|
+
/// (`isPlaybackBufferFull` is deliberately NOT used — it means the fixed
|
|
544
|
+
/// buffer is full, not that the whole track has downloaded.)
|
|
545
|
+
private func computeFullyBuffered() -> Bool {
|
|
546
|
+
guard let item = self.engine?.currentMediaItem else { return false }
|
|
547
|
+
if item.duration.isIndefinite { return false }
|
|
548
|
+
// A local file or a fully-cached source is entirely on disk — fully
|
|
549
|
+
// available regardless of the playback buffer.
|
|
550
|
+
if self.currentTrackSource == .local || self.currentTrackSource == .cached { return true }
|
|
551
|
+
var dur = CMTimeGetSeconds(item.duration)
|
|
552
|
+
if !dur.isFinite || dur <= 0 {
|
|
553
|
+
let idx = self.currentTrackIndex
|
|
554
|
+
guard idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 else {
|
|
555
|
+
return false
|
|
556
|
+
}
|
|
557
|
+
dur = d
|
|
558
|
+
}
|
|
559
|
+
guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return false }
|
|
560
|
+
let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
|
|
561
|
+
return end.isFinite && end >= dur - Self.fullyBufferedEpsilonSeconds
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/// Recompute fully-buffered and fire `onFullyBufferedChange` on a change.
|
|
565
|
+
/// Runs on the buffer-full KVO, the progress tick (the loaded range grows),
|
|
566
|
+
/// and at track change.
|
|
567
|
+
internal func recomputeFullyBuffered() {
|
|
568
|
+
let computed = computeFullyBuffered()
|
|
569
|
+
self.currentFullyBuffered = computed
|
|
570
|
+
guard computed != self.lastEmittedFullyBuffered else { return }
|
|
571
|
+
self.lastEmittedFullyBuffered = computed
|
|
572
|
+
fullyBufferedListeners.forEach { $0(computed) }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/// Cap `target` at the current buffered extent when `clampSeekToBuffered`
|
|
576
|
+
/// is enabled and the target is past what has buffered. A local or fully-
|
|
577
|
+
/// buffered track reports `buffered >= duration >= target`, so nothing is
|
|
578
|
+
/// clamped and a seek reaches the end freely; a partly-buffered stream caps
|
|
579
|
+
/// at the downloaded edge. Reads the live buffered position (not a tracked
|
|
580
|
+
/// mark) so a seek-back that evicted the ahead-buffer clamps to what's there.
|
|
581
|
+
internal func clampToBufferedIfEnabled(_ target: Double) -> Double {
|
|
582
|
+
guard self.config.clampSeekToBuffered == true else { return target }
|
|
583
|
+
guard let buffered = self.engine?.bufferedPositionSeconds, buffered.isFinite, buffered > 0 else {
|
|
584
|
+
return target
|
|
585
|
+
}
|
|
586
|
+
return target > buffered ? buffered : target
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/// Translate `AVPlayer.timeControlStatus` into our `PlayerState`
|
|
590
|
+
/// enum and fire `onStateChange` when the value actually changed.
|
|
591
|
+
/// Consumes `pendingStateChangeReason` (set by transport methods
|
|
592
|
+
/// before they call `play`/`pause`/`stop`, or by the AudioSession
|
|
593
|
+
/// handlers for interruption/route-change events) so user-
|
|
594
|
+
/// initiated transitions are labelled correctly while purely
|
|
595
|
+
/// system-internal ones default to `.system`.
|
|
596
|
+
///
|
|
597
|
+
/// `.buffering` is a system-internal intermediate state — it
|
|
598
|
+
/// shows up between `.paused` → `.playing` while AVPlayer fills
|
|
599
|
+
/// its buffer. We deliberately do NOT consume the pending reason
|
|
600
|
+
/// for `.buffering` so the reason survives to the terminal
|
|
601
|
+
/// `.playing` emit. Otherwise a play() after interruption would
|
|
602
|
+
/// label buffering `.interruption` and the actual `.playing` as
|
|
603
|
+
/// `.system`, which consumers won't expect.
|
|
604
|
+
internal func emitStateChangeIfChanged() {
|
|
605
|
+
guard let engine = self.engine else { return }
|
|
606
|
+
let computed = PlayerStateDerivation.translate(
|
|
607
|
+
status: engine.timeControlStatus,
|
|
608
|
+
currentIndex: self.currentTrackIndex,
|
|
609
|
+
hasCurrentItem: engine.currentMediaItem != nil,
|
|
610
|
+
reachedQueueEnd: self.reachedQueueEnd
|
|
611
|
+
)
|
|
612
|
+
emitState(computed, reason: reasonForComputedState(computed))
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Reason for a derived `PlayerState`, shared by the gapless KVO
|
|
616
|
+
/// (`emitStateChangeIfChanged`) and the crossfade delegate
|
|
617
|
+
/// (`engineStateMaybeChanged`) so both label states identically.
|
|
618
|
+
/// `.buffering` is intermediate — pass through as `.system` and leave the
|
|
619
|
+
/// pending reason for the terminal emit. `.ended` is always end-of-queue —
|
|
620
|
+
/// stamp `.queueEnd` to match `detectQueueEnd` / `enginePlaybackEnded`.
|
|
621
|
+
internal func reasonForComputedState(_ computed: PlayerState) -> StateChangeReason {
|
|
622
|
+
if computed == .buffering {
|
|
623
|
+
return .system
|
|
624
|
+
}
|
|
625
|
+
if computed == .ended {
|
|
626
|
+
self.pendingStateChangeReason = nil
|
|
627
|
+
return .queueEnd
|
|
628
|
+
}
|
|
629
|
+
let reason = self.pendingStateChangeReason ?? .system
|
|
630
|
+
self.pendingStateChangeReason = nil
|
|
631
|
+
return reason
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/// Bypass the timeControlStatus translation and emit a specific
|
|
635
|
+
/// state directly (used for `.ended` / `.error` which AVPlayer
|
|
636
|
+
/// doesn't model as timeControlStatus).
|
|
637
|
+
internal func emitState(_ state: PlayerState, reason: StateChangeReason) {
|
|
638
|
+
// Playback (re)starting clears `reachedQueueEnd` — do this BEFORE the
|
|
639
|
+
// dedup guard so a redundant `.playing` re-emit still clears it.
|
|
640
|
+
if state == .playing { self.reachedQueueEnd = false }
|
|
641
|
+
guard state != self.lastReportedState else { return }
|
|
642
|
+
self.lastReportedState = state
|
|
643
|
+
// Once the track actually plays, a later not-full buffer is a mid-playback
|
|
644
|
+
// `stalled`, not the initial `buffering` load.
|
|
645
|
+
if state == .playing { self.hasStartedPlaying = true }
|
|
646
|
+
self.stateChangeListeners.forEach { $0(state, reason) }
|
|
647
|
+
self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/// Internal entry point for `CastEventBridge` to fan receiver-driven
|
|
651
|
+
/// state into the JS-facing event surface. Goes through the same
|
|
652
|
+
/// `emitState` dedup chokepoint as local-engine transitions, so JS
|
|
653
|
+
/// sees one event per real receiver transition regardless of source.
|
|
654
|
+
func emitRemoteState(_ state: PlayerState, reason: StateChangeReason) {
|
|
655
|
+
// Arrives on main from the Cast SDK's listeners; `emitState` writes the
|
|
656
|
+
// reported-state fields that the local path owns.
|
|
657
|
+
playerQueue.async { self.emitState(state, reason: reason) }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/// Fan a cast receiver's position update to JS `onProgress` so the
|
|
661
|
+
/// consumer's player screen (progress bar / position / duration) tracks
|
|
662
|
+
/// the receiver while a remote session is active. The receiver reports
|
|
663
|
+
/// milliseconds; `PlayerProgress` is in seconds. Buffered arrives as an
|
|
664
|
+
/// absolute receiver position, so it's reduced to "ahead of position"
|
|
665
|
+
/// to match the local path's semantics. The local now-playing / format
|
|
666
|
+
/// path is untouched — the local engine is silent during cast.
|
|
667
|
+
func emitRemoteProgress(positionMs: Int64, durationMs: Int64, bufferedMs: Int64) {
|
|
668
|
+
// Arrives on main from the Cast SDK; the throttle it shares with the local
|
|
669
|
+
// tick is player state.
|
|
670
|
+
playerQueue.async {
|
|
671
|
+
self.emitRemoteProgressOnQueue(
|
|
672
|
+
positionMs: positionMs, durationMs: durationMs, bufferedMs: bufferedMs)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private func emitRemoteProgressOnQueue(
|
|
677
|
+
positionMs: Int64, durationMs: Int64, bufferedMs: Int64
|
|
678
|
+
) {
|
|
679
|
+
guard !progressListeners.isEmpty else { return }
|
|
680
|
+
// Same throttle as the local tick, shared instance — a consumer's
|
|
681
|
+
// configured cadence caps cast progress too (the receiver pushes at its own
|
|
682
|
+
// rate, so the effective cadence is min(receiver rate, configured interval)).
|
|
683
|
+
guard self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else { return }
|
|
684
|
+
let position = TimeInterval(max(0, positionMs)) / 1000.0
|
|
685
|
+
let duration = TimeInterval(max(0, durationMs)) / 1000.0
|
|
686
|
+
let buffered = TimeInterval(max(0, bufferedMs - positionMs)) / 1000.0
|
|
687
|
+
let progress = PlayerProgress(position: position, duration: duration, buffered: buffered)
|
|
688
|
+
progressListeners.forEach { $0(progress) }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/// Fan a cast receiver's track advance to JS `onTrackChange` and update
|
|
692
|
+
/// the authoritative `currentTrackIndex` so the consumer's player screen
|
|
693
|
+
/// follows the receiver's current track. Mirrors the local dispatch's
|
|
694
|
+
/// listener fire without the local-engine now-playing refresh (the cast
|
|
695
|
+
/// lockscreen is driven separately via `CastNowPlayingController`).
|
|
696
|
+
/// Called on the main thread from `CastEventBridge`, like `emitRemoteState`.
|
|
697
|
+
func emitRemoteTrackChange(index: Int) {
|
|
698
|
+
// Arrives on main from the Cast SDK; writes `currentTrackIndex` and reads
|
|
699
|
+
// the track list.
|
|
700
|
+
playerQueue.async { self.emitRemoteTrackChangeOnQueue(index: index) }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private func emitRemoteTrackChangeOnQueue(index: Int) {
|
|
704
|
+
guard index >= 0, index < self.tracks.count else { return }
|
|
705
|
+
self.currentTrackIndex = index
|
|
706
|
+
let track = self.tracks[index]
|
|
707
|
+
// No engine call happens on this path, so nothing downstream reaches
|
|
708
|
+
// `dispatchTrackChange`; do what it does for the source here.
|
|
709
|
+
self.reclassifyCurrentTrackSource()
|
|
710
|
+
self.trackChangeListeners.forEach { $0(track, Double(index), .autoAdvance, nil) }
|
|
711
|
+
// `currentTrackIndex` just moved — recompute skip capability so the
|
|
712
|
+
// receiver's advance updates canSkipPrevious/Next (skip-back enables
|
|
713
|
+
// once off the first track), same as every local index write.
|
|
714
|
+
self.recomputeCapabilities()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/// Hand off current local playback to a newly-connected cast receiver:
|
|
718
|
+
/// load the current queue at the current playhead with the current
|
|
719
|
+
/// play/pause, so playback continues where it was. The local engine is
|
|
720
|
+
/// silenced immediately (after the state read) so the device speaker
|
|
721
|
+
/// never overlaps the receiver. Async — the receiver load is a
|
|
722
|
+
/// round-trip; a failed load leaves local paused (never blasts audio).
|
|
723
|
+
func handoffCurrentPlaybackToCast() {
|
|
724
|
+
// Arrives on main from the Cast SDK. The capture and the local pause are
|
|
725
|
+
// player state, so they run on the queue that owns it — asynchronously,
|
|
726
|
+
// because a synchronous wait here parks main behind whatever the queue is
|
|
727
|
+
// already doing, up to and including a full queue rebuild.
|
|
728
|
+
playerQueue.async { [self] in
|
|
729
|
+
let snapshot = self.buildHandoffSnapshot()
|
|
730
|
+
guard let snapshot else { return }
|
|
731
|
+
self.castEventBridge.seedDeactivationSnapshot(
|
|
732
|
+
trackIndex: snapshot.startIndex,
|
|
733
|
+
positionMs: snapshot.positionMs
|
|
734
|
+
)
|
|
735
|
+
// Chained like every other receiver mirror. This load is absolute, so a
|
|
736
|
+
// delta mirror for a mutation issued just after activation must not
|
|
737
|
+
// complete before it — the load would discard that mutation and leave the
|
|
738
|
+
// receiver permanently diverged from the local queue.
|
|
739
|
+
let previous = self.castMirrorChain
|
|
740
|
+
self.castMirrorChain = Task { [weak self] in
|
|
741
|
+
await previous?.value
|
|
742
|
+
guard let self = self else { return }
|
|
743
|
+
let ids = try? await CastTransportRouter.routeSetQueue(
|
|
744
|
+
tracks: snapshot.items,
|
|
745
|
+
startIndex: snapshot.startIndex,
|
|
746
|
+
startPositionMs: snapshot.positionMs,
|
|
747
|
+
playWhenReady: snapshot.playing
|
|
748
|
+
)
|
|
749
|
+
// A non-nil result means the receiver load succeeded; seed the start
|
|
750
|
+
// track's lockscreen metadata. The current track is then tracked via
|
|
751
|
+
// the receiver's absolute queue index, not these itemIds.
|
|
752
|
+
if ids != nil {
|
|
753
|
+
await self.onPlayerQueue {
|
|
754
|
+
self.castEventBridge.seedStartMetadata(startIndex: snapshot.startIndex)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/// Capture the state a receiver handoff replays, and silence the local
|
|
762
|
+
/// engine so the device speaker never overlaps the receiver. Caller must be
|
|
763
|
+
/// on `playerQueue`.
|
|
764
|
+
private func buildHandoffSnapshot() -> HandoffSnapshot? {
|
|
765
|
+
let tracks = self.tracks
|
|
766
|
+
let idx = self.currentTrackIndex
|
|
767
|
+
let positionMs = Int64(max(0, (self.engine?.currentPositionSeconds ?? 0)) * 1000.0)
|
|
768
|
+
// The transport's intent, not the leg's audible state: a leg that is
|
|
769
|
+
// buffering, or mid-crossfade with its outgoing item finished, reads as
|
|
770
|
+
// not-playing while the consumer is still playing — and the receiver would
|
|
771
|
+
// start paused.
|
|
772
|
+
let playing = self.wantsToPlay
|
|
773
|
+
self.engine?.pause()
|
|
774
|
+
guard idx >= 0, idx < tracks.count else { return nil }
|
|
775
|
+
let items = tracks.compactMap { CastMediaItem.from(track: $0) }
|
|
776
|
+
guard items.count == tracks.count else { return nil }
|
|
777
|
+
return HandoffSnapshot(
|
|
778
|
+
items: items, startIndex: idx, positionMs: positionMs, playing: playing
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private struct HandoffSnapshot {
|
|
783
|
+
let items: [CastMediaItem]
|
|
784
|
+
let startIndex: Int
|
|
785
|
+
let positionMs: Int64
|
|
786
|
+
let playing: Bool
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/// Return local playback to the receiver's last track + position after
|
|
790
|
+
/// a cast session ends. The local engine kept the mirrored queue
|
|
791
|
+
/// (paused) during cast, so this seeks it to the handback point and
|
|
792
|
+
/// stays PAUSED — the user resumes locally. Never auto-blasts audio out
|
|
793
|
+
/// of the phone on disconnect. A `-1` trackIndex (receiver index never
|
|
794
|
+
/// resolved) is a no-op; local stays where it was.
|
|
795
|
+
func resumeLocalAfterCast(trackIndex: Int, positionMs: Int64) {
|
|
796
|
+
// Arrives on main from the Cast SDK; the rebuild below is player state and
|
|
797
|
+
// must not be waited on from main.
|
|
798
|
+
playerQueue.async {
|
|
799
|
+
guard trackIndex >= 0, trackIndex < self.tracks.count else { return }
|
|
800
|
+
let positionSec = max(0, Double(positionMs) / 1000.0)
|
|
801
|
+
self.currentTrackIndex = trackIndex
|
|
802
|
+
// Seated mid-track from the receiver's position: not over, whatever the
|
|
803
|
+
// local queue had reached before the handoff.
|
|
804
|
+
self.reachedQueueEnd = false
|
|
805
|
+
// Rebuild the local engine from the resumed absolute index. The
|
|
806
|
+
// engine was frozen at the cast-start index for the whole cast
|
|
807
|
+
// session, so its queue no longer matches `tracks[trackIndex...]`;
|
|
808
|
+
// a raw `engine.seek(toIndex: trackIndex)` would index the
|
|
809
|
+
// GaplessEngine's remaining-queue space (not the lib's absolute
|
|
810
|
+
// index) and seat the wrong item, leaving a later skipToIndex /
|
|
811
|
+
// play desynced from `currentTrackIndex`. The rebuild re-seats the
|
|
812
|
+
// current item so transport stays consistent.
|
|
813
|
+
self.performingMutation { self.fullRebuildPlayerQueue() }
|
|
814
|
+
// Seek within the freshly-seated current item to the receiver's last
|
|
815
|
+
// position. Resolved by identity: the installed run reaches behind the
|
|
816
|
+
// playhead on the crossfade engine, so engine-index 0 is a track already
|
|
817
|
+
// played, not the current one. No seek at all beats a seek to the wrong
|
|
818
|
+
// track, so an unresolvable position leaves it where the rebuild seated
|
|
819
|
+
// it.
|
|
820
|
+
if positionSec > 0,
|
|
821
|
+
let engineIdx = self.engineIndex(forQueuePosition: trackIndex) {
|
|
822
|
+
self.engine?.seek(toIndex: engineIdx, position: positionSec)
|
|
823
|
+
}
|
|
824
|
+
// Stay paused — the user resumes locally; never auto-blast the
|
|
825
|
+
// phone speaker on revert.
|
|
826
|
+
self.engine?.pause()
|
|
827
|
+
self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
|
|
828
|
+
self.rescheduleLookahead()
|
|
829
|
+
self.recomputeCapabilities()
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/// Real end-of-track signal. AVPlayerItemDidPlayToEndTime fires
|
|
834
|
+
/// when an item finishes playback naturally — NOT when
|
|
835
|
+
/// AVQueuePlayer chain-advances through unbuffered items during a
|
|
836
|
+
/// rebuild. The lib uses this distinction to:
|
|
837
|
+
///
|
|
838
|
+
/// - Natural auto-advance: notification fires → set
|
|
839
|
+
/// `didPlayToEndPending` → `handleCurrentItemDidChange` runs
|
|
840
|
+
/// `matchTrackIndex` resync → `currentTrackIndex` updates to
|
|
841
|
+
/// the auto-advanced position.
|
|
842
|
+
/// - Chain-advance bounce: notification does NOT fire (items
|
|
843
|
+
/// weren't actually playing). `\.currentItem` KVO still fires
|
|
844
|
+
/// from the rebuild's insert sequence, but
|
|
845
|
+
/// `handleCurrentItemDidChange`'s gate sees
|
|
846
|
+
/// `didPlayToEndPending == false` and skips the resync —
|
|
847
|
+
/// `currentTrackIndex` stays at whatever the user mutation
|
|
848
|
+
/// pre-wrote.
|
|
849
|
+
///
|
|
850
|
+
/// Repeat-track handling: when `repeatModeState == .track`, seek
|
|
851
|
+
/// the just-finished item back to zero + replay. The gapless engine
|
|
852
|
+
/// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
|
|
853
|
+
/// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
|
|
854
|
+
/// player does not advance and `player.currentItem` is still the
|
|
855
|
+
/// finished item when this async block runs — the rewind is
|
|
856
|
+
/// deterministic, and the seek-to-zero produces no `\.currentItem`
|
|
857
|
+
/// change to gate. `didPlayToEndPending` is armed in this branch only
|
|
858
|
+
/// when the player has already advanced past the finished item.
|
|
859
|
+
@objc internal func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
|
|
860
|
+
// Capture the repeat mode as the item ends, before the hop to the owner
|
|
861
|
+
// queue. `setRepeatMode` writes `actionAtItemEnd`, so a toggle landing
|
|
862
|
+
// inside that hop would have the branch below decide against a mode that
|
|
863
|
+
// was not in force when the player acted on the end of the track. This
|
|
864
|
+
// runs on AVFoundation's thread; the read is lock-guarded for it.
|
|
865
|
+
let repeatModeAtEnd = self.repeatModeState
|
|
866
|
+
playerQueue.async { [weak self] in
|
|
867
|
+
guard let self else { return }
|
|
868
|
+
// End-of-track sleep timer: pause at the current track's real end —
|
|
869
|
+
// before the repeat-track rewind or the auto-advance — so the pause
|
|
870
|
+
// lands exactly at the boundary and fires even when the track duration
|
|
871
|
+
// was never known. Under repeat-one this pauses at the first natural end.
|
|
872
|
+
let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
|
|
873
|
+
if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
|
|
874
|
+
// Branch on repeat-track FIRST, separately from the
|
|
875
|
+
// currentItem === item check.
|
|
876
|
+
if repeatModeAtEnd == .track {
|
|
877
|
+
// Repeat-track: rewind the just-finished item + replay. The
|
|
878
|
+
// gapless engine holds the item via `actionAtItemEnd = .none`
|
|
879
|
+
// under `.track`, so the player does not advance and
|
|
880
|
+
// `currentItem` is still the finished item here.
|
|
881
|
+
if let player = self.player {
|
|
882
|
+
// Repeat-one loop = a new milestone playthrough of the same track.
|
|
883
|
+
// The gapless engine fires no track-change / delegate here, so
|
|
884
|
+
// dispatchTrackChange's reset is skipped — reset directly, and
|
|
885
|
+
// UNCONDITIONALLY for the gapless engine (self.player non-nil), so
|
|
886
|
+
// milestones re-arm for the new playthrough. (CrossfadeEngine has
|
|
887
|
+
// self.player == nil here and resets via engineDidPlayItemToEnd
|
|
888
|
+
// instead — no double reset.)
|
|
889
|
+
self.milestoneTracker.reset()
|
|
890
|
+
if let item = notification.object as? AVPlayerItem,
|
|
891
|
+
player.currentItem === item {
|
|
892
|
+
item.seek(to: .zero, completionHandler: nil)
|
|
893
|
+
// Replay through the engine so the user's playback speed is
|
|
894
|
+
// restored (a raw AVPlayer.play() resets rate to 1.0) — unless the
|
|
895
|
+
// end-of-track sleep timer just fired, in which case we pause at
|
|
896
|
+
// this natural end instead of looping.
|
|
897
|
+
if !sleepFired.pauseNow { self.engine?.play() }
|
|
898
|
+
} else {
|
|
899
|
+
// The finished item is no longer current, so the player advanced
|
|
900
|
+
// before repeat-track took hold — the mode was toggled as the
|
|
901
|
+
// track ended. There is a real move to reconcile, so arm the
|
|
902
|
+
// resync the `\.currentItem` KVO consumes; without it the index
|
|
903
|
+
// keeps naming the previous track, and `dispatchTrackChange`
|
|
904
|
+
// dedups against the stale id so nothing is announced at all.
|
|
905
|
+
self.didPlayToEndPending = true
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
// Repeat-off / repeat-queue: arm the resync gate so
|
|
911
|
+
// `handleCurrentItemDidChange` will run `matchTrackIndex`
|
|
912
|
+
// when the `\.currentItem` KVO fires for the next item.
|
|
913
|
+
self.didPlayToEndPending = true
|
|
914
|
+
let now = DispatchTime.now().uptimeNanoseconds
|
|
915
|
+
self.gaplessGapPrevEndedAtNs = now
|
|
916
|
+
#if DEBUG
|
|
917
|
+
self.gaplessLogLastEndedAtNs = now
|
|
918
|
+
NSLog("[RNQP-GAPLESS] item-ended trackIdx=%d ts_ns=%llu",
|
|
919
|
+
self.currentTrackIndex, now)
|
|
920
|
+
#endif
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/// New `AVPlayerItem` access-log entries arrive multiple times per
|
|
925
|
+
/// second during streaming (per network transfer chunk). Two
|
|
926
|
+
/// concerns share the handler: the bitrate fallback (re-resolves the
|
|
927
|
+
/// now-playing format while the last emitted one has no bitrate) and
|
|
928
|
+
/// DEBUG-only timeline logging.
|
|
929
|
+
///
|
|
930
|
+
/// Once a bitrate lands in `lastEmittedFormat`, later fires for the same
|
|
931
|
+
/// item do not re-resolve. The active-item gate (`item ===
|
|
932
|
+
/// self.activeMediaItem`) drops fires from items the engine has already
|
|
933
|
+
/// advanced past; `reresolveNowPlayingFormatForBitrate` bounds the
|
|
934
|
+
/// attempts per item and checks its `nowPlayingFormatRequestGen` token
|
|
935
|
+
/// so a stale resolve cannot overwrite a newer item's format.
|
|
936
|
+
@objc internal func handleNewAccessLogEntry(_ notification: Notification) {
|
|
937
|
+
playerQueue.async { [weak self] in
|
|
938
|
+
guard let self,
|
|
939
|
+
let item = notification.object as? AVPlayerItem else { return }
|
|
940
|
+
// Not gated on codec. The access log reports whatever the player
|
|
941
|
+
// negotiated, and a format that resolved without a bitrate is worth
|
|
942
|
+
// re-reading whatever it is — MP3 is neither the only nor the usual
|
|
943
|
+
// codec a consumer streams.
|
|
944
|
+
if item === self.activeMediaItem,
|
|
945
|
+
self.lastEmittedFormatItemId == ObjectIdentifier(item),
|
|
946
|
+
let format = self.lastEmittedFormat {
|
|
947
|
+
let hasBitrate: Bool = {
|
|
948
|
+
guard let variant = format.bitrate,
|
|
949
|
+
case .second = variant else { return false }
|
|
950
|
+
return true
|
|
951
|
+
}()
|
|
952
|
+
if !hasBitrate {
|
|
953
|
+
self.reresolveNowPlayingFormatForBitrate()
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
#if DEBUG
|
|
957
|
+
guard let event = item.accessLog()?.events.last else { return }
|
|
958
|
+
let now = DispatchTime.now().uptimeNanoseconds
|
|
959
|
+
let deltaSinceChangedMs = self.gaplessLogLastChangedAtNs > 0
|
|
960
|
+
? (now &- self.gaplessLogLastChangedAtNs) / 1_000_000 : 0
|
|
961
|
+
NSLog("[RNQP-GAPLESS] access-log trackIdx=%d stalls=%ld observedBitrate=%lld transferDurationMs=%d numberOfMediaRequests=%ld delta_since_changed_ms=%llu",
|
|
962
|
+
self.currentTrackIndex,
|
|
963
|
+
event.numberOfStalls,
|
|
964
|
+
Int64(event.observedBitrate),
|
|
965
|
+
Int(event.transferDuration * 1000),
|
|
966
|
+
event.numberOfMediaRequests,
|
|
967
|
+
deltaSinceChangedMs)
|
|
968
|
+
#endif
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#if DEBUG
|
|
973
|
+
/// Error-log entries surface alongside the existing
|
|
974
|
+
/// `AVPlayerItemFailedToPlayToEndTime` notification for a richer
|
|
975
|
+
/// diagnostic record. Compiled out in Release builds.
|
|
976
|
+
@objc internal func handleNewErrorLogEntry(_ notification: Notification) {
|
|
977
|
+
playerQueue.async { [weak self] in
|
|
978
|
+
guard let self,
|
|
979
|
+
let item = notification.object as? AVPlayerItem,
|
|
980
|
+
let log = item.errorLog(),
|
|
981
|
+
let event = log.events.last else { return }
|
|
982
|
+
NSLog("[RNQP-GAPLESS] error-log trackIdx=%d errorDomain=%@ errorCode=%ld errorComment=%@",
|
|
983
|
+
self.currentTrackIndex,
|
|
984
|
+
event.errorDomain,
|
|
985
|
+
event.errorStatusCode,
|
|
986
|
+
event.errorComment ?? "nil")
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
#endif
|
|
990
|
+
|
|
991
|
+
@objc internal func handlePlayerItemFailedToPlayToEndTime(_ notification: Notification) {
|
|
992
|
+
playerQueue.async { [weak self] in
|
|
993
|
+
guard let self else { return }
|
|
994
|
+
let underlying = notification.userInfo?[
|
|
995
|
+
AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
|
|
996
|
+
// Mid-play failures share the same dedup + retry path as
|
|
997
|
+
// load failures — both routed through `dispatchErrorOrRetry`.
|
|
998
|
+
// Real-world repro showed 4× identical AVFoundation "Cannot
|
|
999
|
+
// Open" fires from this notification firing repeatedly when
|
|
1000
|
+
// the player is in a wedged state; the dedup gate suppresses
|
|
1001
|
+
// them, the retry path attempts recovery before surfacing.
|
|
1002
|
+
let item = notification.object as? AVPlayerItem ?? self.player?.currentItem
|
|
1003
|
+
self.dispatchErrorOrRetry(item: item, underlying: underlying)
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/// Construct a richly-populated `PlaybackError` from an underlying
|
|
1008
|
+
/// `NSError` + the already-mapped standardized code. Centralised so
|
|
1009
|
+
/// every iOS error fire site produces the same shape (sanitised
|
|
1010
|
+
/// `nativeMessage`, consistent `fatal` classifier, etc.). Mirrors
|
|
1011
|
+
/// `PlaybackErrorMapping.kt#buildPlaybackError` on Android.
|
|
1012
|
+
///
|
|
1013
|
+
/// `nativeDomainOverride` replaces the underlying `NSError.domain`
|
|
1014
|
+
/// on the emitted error — used by lib-defined error categories
|
|
1015
|
+
/// (`PLAYER_ITEM_LOAD_FAILED`, `AUDIO_SESSION_*`,
|
|
1016
|
+
/// `AUDIO_SESSION_INTERRUPTION_*`) that need a stable
|
|
1017
|
+
/// SCREAMING_SNAKE_CASE marker for JS branching. Nil keeps the raw
|
|
1018
|
+
/// underlying domain for callers that prefer the native string.
|
|
1019
|
+
internal func buildPlaybackError(
|
|
1020
|
+
_ underlying: NSError?,
|
|
1021
|
+
mapped: PlaybackErrorCode,
|
|
1022
|
+
queueItemId: String = "",
|
|
1023
|
+
url: String = "",
|
|
1024
|
+
nativeDomainOverride: String? = nil
|
|
1025
|
+
) -> PlaybackError {
|
|
1026
|
+
let nativeCode = underlying?.code ?? 0
|
|
1027
|
+
let nativeDomain = nativeDomainOverride ?? (underlying?.domain ?? "")
|
|
1028
|
+
let nativeMessage = PlaybackErrorMapping.stripQuery(
|
|
1029
|
+
underlying?.localizedDescription ?? ""
|
|
1030
|
+
)
|
|
1031
|
+
return PlaybackError(
|
|
1032
|
+
code: mapped,
|
|
1033
|
+
message: PlaybackErrorMapping.messageFor(mapped),
|
|
1034
|
+
fatal: !PlaybackErrorMapping.isTransient(mapped),
|
|
1035
|
+
nativeCode: Double(nativeCode),
|
|
1036
|
+
nativeDomain: nativeDomain,
|
|
1037
|
+
nativeMessage: nativeMessage,
|
|
1038
|
+
queueItemId: queueItemId,
|
|
1039
|
+
url: url
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
1042
|
+
}
|