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
|
@@ -19,6 +19,8 @@ import androidx.media3.common.MediaItem
|
|
|
19
19
|
import androidx.media3.common.PlaybackException
|
|
20
20
|
import androidx.media3.common.Player
|
|
21
21
|
import androidx.media3.common.util.UnstableApi
|
|
22
|
+
import androidx.media3.database.StandaloneDatabaseProvider
|
|
23
|
+
import androidx.media3.datasource.cache.SimpleCache
|
|
22
24
|
import com.facebook.react.ReactApplication
|
|
23
25
|
import androidx.media3.exoplayer.ExoPlayer
|
|
24
26
|
import com.facebook.proguard.annotations.DoNotStrip
|
|
@@ -26,6 +28,8 @@ import com.margelo.nitro.NitroModules
|
|
|
26
28
|
import com.margelo.nitro.core.Promise
|
|
27
29
|
import java.util.concurrent.CountDownLatch
|
|
28
30
|
import kotlinx.coroutines.CompletableDeferred
|
|
31
|
+
import kotlinx.coroutines.Job
|
|
32
|
+
import kotlinx.coroutines.launch
|
|
29
33
|
import kotlinx.coroutines.sync.Mutex
|
|
30
34
|
import kotlinx.coroutines.sync.withLock
|
|
31
35
|
import kotlinx.coroutines.withTimeoutOrNull
|
|
@@ -86,9 +90,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
86
90
|
* an on-disk LRU. Built with [LookaheadCache.DEFAULT_MAX_SIZE_BYTES]
|
|
87
91
|
* by default; `setLookaheadCache` lets consumers disable / resize.
|
|
88
92
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
93
|
+
* Written on main (configure / destroy); read off main by
|
|
94
|
+
* `buildCacheStatusSnapshot` from the `Promise.async` pool
|
|
95
|
+
* (`getLookaheadCacheStatus`) and the cache-status IO dispatcher
|
|
96
|
+
* (`emitCacheStatus` / `onCacheStatusChange`), hence @Volatile.
|
|
91
97
|
*/
|
|
98
|
+
@Volatile
|
|
92
99
|
@VisibleForTesting
|
|
93
100
|
internal var lookaheadCache: LookaheadCache? = null
|
|
94
101
|
private set
|
|
@@ -100,22 +107,35 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
100
107
|
* mutation. Default lookahead window = [LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT]
|
|
101
108
|
* (3); [setLookaheadCache] runtime config can override.
|
|
102
109
|
*/
|
|
110
|
+
@Volatile
|
|
103
111
|
@VisibleForTesting
|
|
104
112
|
internal var lookaheadCacheWriter: LookaheadCacheWriter? = null
|
|
105
113
|
private set
|
|
106
114
|
|
|
107
115
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* methods always hop). Persists across destroy → configure so a
|
|
112
|
-
* consumer who set custom values once doesn't lose them on a
|
|
113
|
-
* reconfigure cycle.
|
|
116
|
+
* Watches the default network's transport so prefetch can stop the moment
|
|
117
|
+
* the device hands off to cellular while `allowsCellularAccess` is off.
|
|
118
|
+
* Built with the lookahead stack and released with it.
|
|
114
119
|
*/
|
|
115
120
|
@VisibleForTesting
|
|
121
|
+
internal var cellularTransportMonitor: CellularTransportMonitor? = null
|
|
122
|
+
private set
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Live lookahead cache config — enabled with
|
|
126
|
+
* [LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT] until
|
|
127
|
+
* [setLookaheadCache] mutates it, on main; read on main by the config
|
|
128
|
+
* paths and off it by [buildCacheStatusSnapshot] on the `Promise.async`
|
|
129
|
+
* pool and the cache-status dispatcher, which is why it is `@Volatile`.
|
|
130
|
+
* Persists across destroy → configure so a consumer who set custom
|
|
131
|
+
* values once doesn't lose them on a reconfigure cycle.
|
|
132
|
+
*/
|
|
133
|
+
@Volatile
|
|
134
|
+
@VisibleForTesting
|
|
116
135
|
internal var lookaheadConfig: LookaheadCacheConfig = LookaheadCacheConfig(
|
|
117
136
|
enabled = true,
|
|
118
|
-
lookaheadCount = LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT.toDouble()
|
|
137
|
+
lookaheadCount = LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT.toDouble(),
|
|
138
|
+
allowsCellularAccess = true
|
|
119
139
|
)
|
|
120
140
|
|
|
121
141
|
/** Configure-time cache disk budget in MB — `PlayerConfig.lookaheadCacheMaxSizeMb`
|
|
@@ -131,6 +151,130 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
131
151
|
private fun evictionPolicyFrom(cfg: PlayerConfig): EvictionPolicy =
|
|
132
152
|
cfg.lookaheadCacheEvictionPolicy ?: EvictionPolicy.LRU
|
|
133
153
|
|
|
154
|
+
private fun cachePrefs(context: Context) =
|
|
155
|
+
context.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE)
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The eviction policy the cache on disk was last built with, or null before
|
|
159
|
+
* the first configure. Read from disk because the in-memory config starts at
|
|
160
|
+
* the default in every fresh process, which cannot distinguish "unchanged"
|
|
161
|
+
* from "changed back to the default".
|
|
162
|
+
*/
|
|
163
|
+
private fun persistedEvictionPolicy(context: Context): EvictionPolicy? =
|
|
164
|
+
// The live cache wins while the process holds one: it is what the policy
|
|
165
|
+
// describes, and the prefs write is asynchronous, so a quick reconfigure
|
|
166
|
+
// would otherwise read a stale value and wipe a correct cache.
|
|
167
|
+
LookaheadCache.appliedEvictionPolicy(defaultCacheDirectory(context))
|
|
168
|
+
?: cachePrefs(context).getString(KEY_EVICTION_POLICY, null)
|
|
169
|
+
?.let { name -> EvictionPolicy.entries.firstOrNull { it.name == name } }
|
|
170
|
+
|
|
171
|
+
private fun defaultCacheDirectory(context: Context) =
|
|
172
|
+
java.io.File(context.applicationContext.cacheDir, LookaheadCache.DEFAULT_CACHE_DIR_NAME)
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `commit()`, not `apply()`: this has to survive a process kill. A queued
|
|
176
|
+
* write lost to one leaves the previous policy recorded, and the next launch
|
|
177
|
+
* reads that as a policy change and wipes a cache that is already correct.
|
|
178
|
+
*/
|
|
179
|
+
private fun persistEvictionPolicy(context: Context, policy: EvictionPolicy) {
|
|
180
|
+
val appCtx = context.applicationContext
|
|
181
|
+
cacheIoScope.launch {
|
|
182
|
+
runCatching {
|
|
183
|
+
val prefs = cachePrefs(appCtx)
|
|
184
|
+
if (prefs.getString(KEY_EVICTION_POLICY, null) != policy.name) {
|
|
185
|
+
prefs.edit().putString(KEY_EVICTION_POLICY, policy.name).commit()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Drop the on-disk cache: rename it out of the way on the caller's thread,
|
|
193
|
+
* then delete the files and the index tables off it.
|
|
194
|
+
*
|
|
195
|
+
* The rename is the load-bearing part. `configureInternal` rebuilds a
|
|
196
|
+
* `SimpleCache` in this same directory a few statements later, and
|
|
197
|
+
* `SimpleCache.initialize` reads the directory as it starts; a delete still
|
|
198
|
+
* running underneath makes that read fail and the cache throws for the rest
|
|
199
|
+
* of its life. Renaming is atomic within a filesystem, so the rebuild sees a
|
|
200
|
+
* clean path immediately and the slow part happens where nothing is watching.
|
|
201
|
+
*/
|
|
202
|
+
private fun discardCacheDirectory(context: Context): Job? {
|
|
203
|
+
val appCtx = context.applicationContext
|
|
204
|
+
val cacheDir = defaultCacheDirectory(appCtx)
|
|
205
|
+
// A cache built by a previous process has no shared instance to release, so
|
|
206
|
+
// open a provider for it — the index tables are keyed by cache uid and
|
|
207
|
+
// outlive the files, and deleting the directory alone strands one per
|
|
208
|
+
// policy change.
|
|
209
|
+
val provider = LookaheadCache.releaseSharedInstance(cacheDir)
|
|
210
|
+
?: runCatching { StandaloneDatabaseProvider(appCtx) }.getOrNull()
|
|
211
|
+
val doomed = java.io.File(
|
|
212
|
+
appCtx.cacheDir,
|
|
213
|
+
"${LookaheadCache.DEFAULT_CACHE_DIR_NAME}$DELETING_DIR_SUFFIX${System.nanoTime()}"
|
|
214
|
+
)
|
|
215
|
+
// Claimed before the rename, so the sweep — which runs off the looper and
|
|
216
|
+
// may list the directory at any moment — never collects a directory this
|
|
217
|
+
// process is deleting: otherwise it would match the name the rename just
|
|
218
|
+
// produced and start a second delete of the same tree, with a second
|
|
219
|
+
// database provider dropping the same index tables.
|
|
220
|
+
claimDoomedDirectory(doomed)
|
|
221
|
+
if (!cacheDir.exists() || !cacheDir.renameTo(doomed)) {
|
|
222
|
+
releaseDoomedDirectory(doomed)
|
|
223
|
+
// Without the rename the directory is still the one the rebuild is about
|
|
224
|
+
// to use, so the delete has to finish before this returns. Doing it off
|
|
225
|
+
// thread here would race the rebuild and can drop the *new* cache's index
|
|
226
|
+
// tables, which is worse than the main-thread cost of a path that only
|
|
227
|
+
// fires when a same-filesystem rename fails.
|
|
228
|
+
deleteCacheDirectory(cacheDir, provider)
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
return cacheIoScope.launch {
|
|
232
|
+
try {
|
|
233
|
+
deleteCacheDirectory(doomed, provider)
|
|
234
|
+
} finally {
|
|
235
|
+
releaseDoomedDirectory(doomed)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private fun deleteCacheDirectory(
|
|
241
|
+
dir: java.io.File,
|
|
242
|
+
provider: StandaloneDatabaseProvider?
|
|
243
|
+
) {
|
|
244
|
+
runCatching {
|
|
245
|
+
// Reads the uid from inside `dir`, so it drops the index tables belonging
|
|
246
|
+
// to this cache and no other.
|
|
247
|
+
if (provider != null) SimpleCache.delete(dir, provider) else dir.deleteRecursively()
|
|
248
|
+
}
|
|
249
|
+
runCatching { provider?.close() }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Delete directories left behind by a discard that did not finish — a process
|
|
254
|
+
* death between the rename and the delete strands one, and nothing else would
|
|
255
|
+
* ever collect it.
|
|
256
|
+
*/
|
|
257
|
+
private fun sweepAbandonedCacheDirectories(context: Context) {
|
|
258
|
+
val appCtx = context.applicationContext
|
|
259
|
+
val prefix = "${LookaheadCache.DEFAULT_CACHE_DIR_NAME}$DELETING_DIR_SUFFIX"
|
|
260
|
+
// Listing and deleting both run off the looper; a directory this process
|
|
261
|
+
// is deleting is claimed before it is renamed into this name pattern, so
|
|
262
|
+
// the listing cannot pick one up mid-discard.
|
|
263
|
+
cacheIoScope.launch {
|
|
264
|
+
val orphans = appCtx.cacheDir.listFiles()
|
|
265
|
+
?.filter {
|
|
266
|
+
it.isDirectory && it.name.startsWith(prefix) && !isDoomedDirectoryClaimed(it)
|
|
267
|
+
}
|
|
268
|
+
?: return@launch
|
|
269
|
+
if (orphans.isEmpty()) return@launch
|
|
270
|
+
// Each orphan still carries its own `.uid`, so route it through the same
|
|
271
|
+
// delete to take its index tables with it.
|
|
272
|
+
val provider = runCatching { StandaloneDatabaseProvider(appCtx) }.getOrNull()
|
|
273
|
+
orphans.forEach { runCatching { if (provider != null) SimpleCache.delete(it, provider) else it.deleteRecursively() } }
|
|
274
|
+
runCatching { provider?.close() }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
134
278
|
/**
|
|
135
279
|
* Cache-status listeners registered via [onCacheStatusChange].
|
|
136
280
|
* Multi-listener registry — every JS consumer that subscribes gets
|
|
@@ -198,6 +342,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
198
342
|
networkTimeoutMs = null,
|
|
199
343
|
audioContentType = null,
|
|
200
344
|
audioCategoryOptions = null,
|
|
345
|
+
// iOS-only opt-in; Android has no in-app vocabulary donation API and
|
|
346
|
+
// ignores it, as `donateVoiceVocabulary` itself does.
|
|
347
|
+
voiceVocabularyDonationEnabled = null,
|
|
201
348
|
visualizationEnabled = null,
|
|
202
349
|
clampSeekToBuffered = null,
|
|
203
350
|
lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
|
|
@@ -248,29 +395,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
248
395
|
private var playbackModeState: PlaybackModeStateMachine.State =
|
|
249
396
|
PlaybackModeStateMachine.Initial
|
|
250
397
|
|
|
251
|
-
/**
|
|
252
|
-
* Subscribe to cast-route activate/deactivate transitions.
|
|
253
|
-
* Push-PCM sessions (AirPlay 1/2) force-degrade the active engine
|
|
254
|
-
* from Crossfade to Gapless because the wire protocol cannot mix
|
|
255
|
-
* two ExoPlayers into one sink; clearing the session re-applies
|
|
256
|
-
* the user's requested mode (so crossfade re-instates).
|
|
257
|
-
*
|
|
258
|
-
* The disposer is intentionally discarded — TrackPlayer is a
|
|
259
|
-
* process-singleton `HybridObject`; the listener lives for the
|
|
260
|
-
* lifetime of the JVM and the disposer would only matter if a
|
|
261
|
-
* second TrackPlayer instance ever existed.
|
|
262
|
-
*/
|
|
263
|
-
@Suppress("unused")
|
|
264
|
-
private val castRouteDisposer: () -> Unit =
|
|
265
|
-
com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
|
|
266
|
-
// Re-apply against the user's requested mode on every flip.
|
|
267
|
-
// `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
|
|
268
|
-
// for the degrade decision, so the swap settles to the right
|
|
269
|
-
// engine for the new route state.
|
|
270
|
-
val mode = playbackModeState.active
|
|
271
|
-
mainHandler.post { applyEngineSwapForMode(mode) }
|
|
272
|
-
}
|
|
273
|
-
|
|
274
398
|
/**
|
|
275
399
|
* Bridges Chromecast `RemotePlayer` session events back to the
|
|
276
400
|
* lib's existing JS event surface (`onStateChange` etc.). Lives for
|
|
@@ -278,7 +402,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
278
402
|
* `PlaybackStateRouter.activeFlow` and subscribes / unsubscribes to
|
|
279
403
|
* the active session's StateFlows on every transition.
|
|
280
404
|
*/
|
|
281
|
-
@Suppress("unused")
|
|
282
405
|
private val castEventBridge = com.margelo.nitro.queueplayer.cast.CastEventBridge(this).also {
|
|
283
406
|
it.start()
|
|
284
407
|
}
|
|
@@ -304,6 +427,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
304
427
|
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
|
|
305
428
|
)
|
|
306
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Scope for cache file work that must not run on the app looper — recursive
|
|
432
|
+
* directory deletes and the SQLite index drop that goes with them.
|
|
433
|
+
*
|
|
434
|
+
* Process-lifetime and deliberately not cancelled on destroy: a delete that
|
|
435
|
+
* outlives a `destroy()` still has to finish, or the renamed directory it is
|
|
436
|
+
* clearing leaks until the next launch sweeps it.
|
|
437
|
+
*/
|
|
438
|
+
private val cacheIoScope =
|
|
439
|
+
kotlinx.coroutines.CoroutineScope(
|
|
440
|
+
kotlinx.coroutines.SupervisorJob() +
|
|
441
|
+
kotlinx.coroutines.Dispatchers.IO +
|
|
442
|
+
// Cache housekeeping must never take the process down. Off the main
|
|
443
|
+
// thread there is no JS call to surface a failure to, and the default
|
|
444
|
+
// handler treats an uncaught one as fatal.
|
|
445
|
+
kotlinx.coroutines.CoroutineExceptionHandler { _, t ->
|
|
446
|
+
Log.w(TAG, "cache maintenance failed", t)
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Cache-status snapshots, built one at a time.
|
|
452
|
+
*
|
|
453
|
+
* `onTrackProcessed` fires one of these per prefetched track, and on the
|
|
454
|
+
* unbounded IO dispatcher two builds can finish in either order and deliver
|
|
455
|
+
* out of sequence — a subscriber would watch `tracksFullyCached` go
|
|
456
|
+
* backwards. Serialising the builds keeps the deliveries in the order the
|
|
457
|
+
* snapshots were taken.
|
|
458
|
+
*/
|
|
459
|
+
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
460
|
+
private val cacheStatusDispatcher =
|
|
461
|
+
kotlinx.coroutines.Dispatchers.IO.limitedParallelism(1)
|
|
462
|
+
|
|
307
463
|
/**
|
|
308
464
|
* Connection to the in-process [PlaybackService]. `onServiceConnected`
|
|
309
465
|
* stashes the [PlaybackService.LocalBinder] and completes
|
|
@@ -324,13 +480,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
324
480
|
// landed; the next disconnect starts the backoff fresh.
|
|
325
481
|
rebindAttempt = 0
|
|
326
482
|
rebindHandler.removeCallbacks(rebindRunnable)
|
|
327
|
-
// Apply
|
|
328
|
-
// before the
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// and Android Auto's later bind times out waiting for the JS
|
|
333
|
-
// carConnect callback.
|
|
483
|
+
// Apply browse-callback registrations made while [serviceBinder]
|
|
484
|
+
// was null — before the first bind, or between a service kill and
|
|
485
|
+
// this rebind. Without the drain those registrations never reach
|
|
486
|
+
// the service and `dispatchCarConnect` times out waiting for a JS
|
|
487
|
+
// carConnect callback that was never installed.
|
|
334
488
|
drainPendingBrowseRegistrations()
|
|
335
489
|
drainPendingSnapshot()
|
|
336
490
|
// Re-apply the remote-control config to the (possibly reborn) service.
|
|
@@ -544,7 +698,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
544
698
|
// simultaneously without clobbering each other.
|
|
545
699
|
private val stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Unit>()
|
|
546
700
|
private val trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Unit>()
|
|
547
|
-
private val queueChangeListeners = ListenerRegistry<(
|
|
701
|
+
private val queueChangeListeners = ListenerRegistry<(QueueChangeDelta, Double, QueueChangeReason) -> Unit>()
|
|
548
702
|
private val progressListeners = ListenerRegistry<(PlayerProgress) -> Unit>()
|
|
549
703
|
// Buffer state — discrete empty/buffering/stalled/full for the active track, derived
|
|
550
704
|
// from Media3's playback state + buffered-ahead. Recomputed on the engine
|
|
@@ -585,17 +739,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
585
739
|
private var progressLifecycleObserver: DefaultLifecycleObserver? = null
|
|
586
740
|
private val errorListeners = ListenerRegistry<(PlaybackError) -> Unit>()
|
|
587
741
|
private val queueEndListeners = ListenerRegistry<() -> Unit>()
|
|
588
|
-
// SkipCapability struct
|
|
589
|
-
//
|
|
590
|
-
//
|
|
742
|
+
// Workaround: the callback carries a `SkipCapability` struct rather than
|
|
743
|
+
// two boolean arguments, because Nitrogen miscompiles a `(bool, bool) ->
|
|
744
|
+
// void` callback. No margelo/nitro issue found for it.
|
|
591
745
|
private val skipCapabilityListeners = ListenerRegistry<(SkipCapability) -> Unit>()
|
|
592
746
|
|
|
593
|
-
/**
|
|
594
|
-
* Last-emitted skip capability tuple. Native owns the truth; the
|
|
595
|
-
* JS layer is a passive observer. Recomputed via
|
|
596
|
-
* [recomputeCapabilities] at every `tracks` / `currentTrackIndex`
|
|
597
|
-
* / `repeatModeState` write site, with dedup.
|
|
598
|
-
*/
|
|
599
747
|
/**
|
|
600
748
|
* Authoritative snapshot of (canSkipPrevious, canSkipNext) — single
|
|
601
749
|
* `@Volatile` reference write keeps the pair atomic across cross-
|
|
@@ -643,12 +791,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
643
791
|
private var gaplessGapPrevEndedAtNs: Long = 0L
|
|
644
792
|
|
|
645
793
|
/**
|
|
646
|
-
* Debug-only
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* stays (16 bytes total — negligible) but is never accessed in prod.
|
|
794
|
+
* Debug-only timestamp backing the verbose `RNQP-GAPLESS` Log.d emits.
|
|
795
|
+
* Every read and write sits inside `if (BuildConfig.DEBUG)`, so R8
|
|
796
|
+
* strips the branches in Release builds; only the field itself remains.
|
|
650
797
|
*/
|
|
651
|
-
private var gaplessLogLastEndedAtNs: Long = 0L
|
|
652
798
|
private var gaplessLogLastChangedAtNs: Long = 0L
|
|
653
799
|
|
|
654
800
|
/** Stashed by transport methods before calling player.play()/pause()/stop()/seekTo().
|
|
@@ -694,15 +840,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
694
840
|
* initiation so the retry-then-fail can fire a fresh error. */
|
|
695
841
|
private var lastErrorKey: Pair<Int, PlaybackErrorCode>? = null
|
|
696
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Positions whose playback error has been reported to the consumer and not
|
|
845
|
+
* recovered since. Empty when nothing is outstanding, and `-1` is never a
|
|
846
|
+
* member — a failure carrying no track records nothing, which is what keeps
|
|
847
|
+
* `currentTrackIndex in this` from reading as stopped while no track is
|
|
848
|
+
* seated.
|
|
849
|
+
*
|
|
850
|
+
* A set, not one position: under crossfade the standby leg prerolls the next
|
|
851
|
+
* track, so a broken one is reported while the leading track still plays and
|
|
852
|
+
* both are outstanding at once. Holding only the latest would hand the
|
|
853
|
+
* listener the standby's failure a second time when the engine seats it.
|
|
854
|
+
* The queue is stopped when `currentTrackIndex` is a member.
|
|
855
|
+
*
|
|
856
|
+
* Written only where [handlePlayerError] hands an `onError` out, so it names
|
|
857
|
+
* a track the consumer has been told about and nothing has recovered.
|
|
858
|
+
* [retryFailedItem] and every skip drop the entry for the position they
|
|
859
|
+
* attempt; a track change keeps only the announced position's; `setQueue`,
|
|
860
|
+
* `clearQueue`, `destroy` and `shuffleQueue` empty it, and `addToQueue`,
|
|
861
|
+
* `removeFromQueue` and `moveInQueue` keep only the current track's, carried
|
|
862
|
+
* to its new index. `onCrossfadeCancel` deliberately keeps the set: it
|
|
863
|
+
* returns to the track already playing rather than announcing a new one, and
|
|
864
|
+
* the prerolled track is still queued and still broken. Deliberately NOT a
|
|
865
|
+
* PLAYING emit: a source the player keeps re-attempting flips to playing
|
|
866
|
+
* between attempts, so clearing there would reopen a track's report.
|
|
867
|
+
*
|
|
868
|
+
* Separate from [lastErrorKey] even though both name a failing track: that
|
|
869
|
+
* one exists to dedup repeat reports and is cleared whenever a repeat would
|
|
870
|
+
* be wanted again, which is not the same question as whether the queue is
|
|
871
|
+
* still stopped. Mirrors the iOS `reportedFailureQueueItemIds` set.
|
|
872
|
+
*/
|
|
873
|
+
private val reportedFailureIndices = mutableSetOf<Int>()
|
|
874
|
+
|
|
697
875
|
/** Auto-retry counter per queue position, keyed on
|
|
698
876
|
* `currentTrackIndex` (lib-internal authoritative position; never
|
|
699
877
|
* derived from consumer fields). Decremented on each
|
|
700
878
|
* transient-error fire; when 0, the error surfaces to JS.
|
|
701
879
|
* Re-populated on `setQueueInternal` /
|
|
702
880
|
* `addToQueueInternal` from `effectiveAutoRetries()` (clamped
|
|
703
|
-
* 0..5; default
|
|
704
|
-
*
|
|
705
|
-
*
|
|
881
|
+
* 0..5; default 1). Restored to the configured value by `retry()`, so
|
|
882
|
+
* a manual attempt gives whatever fails next its configured automatic
|
|
883
|
+
* allowance. `play()` restores the same configured value when the counter
|
|
884
|
+
* is 0 — stuck-recovery on a user re-tap. Cleared on `removeFromQueue` / `moveInQueue`
|
|
706
885
|
* (index shifts invalidate keys; budget resets are an acceptable
|
|
707
886
|
* trade-off vs walking + remapping the map per shifted index). */
|
|
708
887
|
private var retryAttemptsRemaining: MutableMap<Int, Int> = mutableMapOf()
|
|
@@ -874,25 +1053,34 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
874
1053
|
internal fun configureInternal(newConfig: PlayerConfig, context: Context) {
|
|
875
1054
|
// Eviction policy is fixed at SimpleCache construction; a change clears the
|
|
876
1055
|
// cache + rebuilds under the new evictor (infrequent — see FifoCacheEvictor).
|
|
877
|
-
//
|
|
878
|
-
// destroyInternal
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
1056
|
+
// The service still holds the engine `configurePlayer` replaces further
|
|
1057
|
+
// down — `destroyInternal` drops this player's references, not the
|
|
1058
|
+
// service's engine — and that engine reads through the shared cache, so it
|
|
1059
|
+
// is stopped before the cache is released and its directory renamed away.
|
|
1060
|
+
// Compare against what was last APPLIED, not against the in-memory config.
|
|
1061
|
+
// A fresh process starts at the LRU default, so a FIFO consumer would
|
|
1062
|
+
// otherwise read every cold launch as a policy change and wipe its cache
|
|
1063
|
+
// before it could ever be used.
|
|
1064
|
+
val previousEvictionPolicy =
|
|
1065
|
+
persistedEvictionPolicy(context) ?: evictionPolicyFrom(this.config)
|
|
882
1066
|
this.config = newConfig
|
|
883
1067
|
// Swap the cover-art placeholder bitmap to the consumer-supplied image
|
|
884
1068
|
// (or back to the built-in when unset). The no-art metadata URI is
|
|
885
1069
|
// threaded separately at each buildMediaItems call.
|
|
886
1070
|
PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
|
|
887
1071
|
if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
|
|
1072
|
+
serviceBinder?.engine?.stop()
|
|
888
1073
|
lookaheadCacheWriter?.release()
|
|
889
1074
|
lookaheadCacheWriter = null
|
|
890
1075
|
lookaheadCache?.release()
|
|
891
1076
|
lookaheadCache = null
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1077
|
+
cellularTransportMonitor?.stop()
|
|
1078
|
+
cellularTransportMonitor = null
|
|
1079
|
+
discardCacheDirectory(context)
|
|
895
1080
|
}
|
|
1081
|
+
// Collect what a previous process left mid-delete. Directories this
|
|
1082
|
+
// process is still deleting are claimed and skipped.
|
|
1083
|
+
sweepAbandonedCacheDirectories(context)
|
|
896
1084
|
// Seed the progress-emission throttle from config and start observing app
|
|
897
1085
|
// background/foreground. Both are idempotent, so a re-configure refreshes
|
|
898
1086
|
// the rate without double-registering the lifecycle observer.
|
|
@@ -907,6 +1095,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
907
1095
|
// new field.
|
|
908
1096
|
VisualizationGate.setEnabled(newConfig.visualizationEnabled ?: true)
|
|
909
1097
|
buildLookaheadStack(newConfig, context)
|
|
1098
|
+
// Recorded only once a cache actually exists under this policy, so a failed
|
|
1099
|
+
// build cannot leave a policy stamped that nothing was built with.
|
|
1100
|
+
if (lookaheadCache != null) {
|
|
1101
|
+
persistEvictionPolicy(context, evictionPolicyFrom(newConfig))
|
|
1102
|
+
}
|
|
1103
|
+
// configure() destroys first, so the queue is empty here and this clears
|
|
1104
|
+
// protection rather than seeding it. That is the point: the evictor lives
|
|
1105
|
+
// in a process-wide shared instance that outlives a destroy/configure
|
|
1106
|
+
// cycle, so without this it would keep shielding the previous session's
|
|
1107
|
+
// tracks until the next queue mutation.
|
|
1108
|
+
refreshCacheProtection()
|
|
910
1109
|
val mediaSourceFactory =
|
|
911
1110
|
MediaItemBuilder.buildMediaSourceFactory(context, newConfig, lookaheadCache)
|
|
912
1111
|
val binder = serviceBinder
|
|
@@ -943,6 +1142,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
943
1142
|
engine.setVolume(volumeState)
|
|
944
1143
|
engine.setPlaybackSpeed(playbackSpeedState)
|
|
945
1144
|
engine.setPitchCorrectionMode(pitchCorrectionModeState)
|
|
1145
|
+
installSuppliedReplayGainResolver(engine)
|
|
946
1146
|
}
|
|
947
1147
|
installEventObservers()
|
|
948
1148
|
binder.setCommandHandler(mediaSessionCommandHandler)
|
|
@@ -950,7 +1150,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
950
1150
|
}
|
|
951
1151
|
|
|
952
1152
|
/**
|
|
953
|
-
* Bridge from
|
|
1153
|
+
* Bridge from `PlaybackService` — its [SessionCommandForwardingPlayer],
|
|
1154
|
+
* [MediaButtonDispatch] and audio-focus listener — back into this
|
|
954
1155
|
* TrackPlayer. Stamps the `system` pending-reason for transports
|
|
955
1156
|
* that the system surface drives (lock-screen, Bluetooth, Android
|
|
956
1157
|
* Auto, hardware media keys), and routes skip-next / skip-previous
|
|
@@ -1089,6 +1290,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1089
1290
|
* not a correctness gate.
|
|
1090
1291
|
*/
|
|
1091
1292
|
private fun buildLookaheadStack(config: PlayerConfig, context: Context) {
|
|
1293
|
+
// Ahead of the writer, which captures the monitor's reading at every
|
|
1294
|
+
// reschedule, and a transport flip has to move the window itself. Built
|
|
1295
|
+
// regardless of `enabled` for the same reason the cache is: prefetch can
|
|
1296
|
+
// be turned on at runtime, and the writer built then needs a monitor.
|
|
1297
|
+
cellularTransportMonitor?.stop()
|
|
1298
|
+
cellularTransportMonitor = CellularTransportMonitor(context).apply {
|
|
1299
|
+
// The callback arrives on a ConnectivityManager thread; the reschedule
|
|
1300
|
+
// is main-confined.
|
|
1301
|
+
onTransportChanged = { mainHandler.post { rescheduleLookahead() } }
|
|
1302
|
+
start()
|
|
1303
|
+
}
|
|
1092
1304
|
// Build the cache regardless of `enabled` so caching can be turned on at
|
|
1093
1305
|
// runtime (setLookaheadCacheInternal) without a configure() cycle. Media3's
|
|
1094
1306
|
// SimpleCache fixes its size at construction and allows one instance per
|
|
@@ -1118,6 +1330,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1118
1330
|
null
|
|
1119
1331
|
}
|
|
1120
1332
|
lookaheadCache = cache
|
|
1333
|
+
cache?.servesPlayback = lookaheadConfig.enabled
|
|
1121
1334
|
// Writer + player factory share state via the cache, not via the
|
|
1122
1335
|
// http factory instance. Writer is only useful when there's a
|
|
1123
1336
|
// cache to write into — gate on both `lookaheadConfig.enabled`
|
|
@@ -1131,10 +1344,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1131
1344
|
networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
|
|
1132
1345
|
config.networkTimeoutMs
|
|
1133
1346
|
)
|
|
1134
|
-
)
|
|
1347
|
+
),
|
|
1348
|
+
isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
|
|
1135
1349
|
).apply {
|
|
1136
1350
|
defaultLookaheadCount = lookaheadConfig.lookaheadCount.toInt()
|
|
1137
1351
|
.coerceAtLeast(0)
|
|
1352
|
+
allowsCellularAccess = lookaheadConfig.allowsCellularAccess ?: true
|
|
1138
1353
|
onTrackProcessed = { onMainPostStatusEmit() }
|
|
1139
1354
|
}
|
|
1140
1355
|
} else {
|
|
@@ -1142,20 +1357,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1142
1357
|
}
|
|
1143
1358
|
}
|
|
1144
1359
|
|
|
1145
|
-
/**
|
|
1146
|
-
* Tear down the ExoPlayer + lookahead writer + cache instances so
|
|
1147
|
-
* the configure() rebuild path (headers/userAgent change) can
|
|
1148
|
-
* recreate them cleanly. The on-disk cache directory is preserved
|
|
1149
|
-
* — `SimpleCache.release()` only frees the in-memory instance and
|
|
1150
|
-
* its file lock; the entries stay on disk and the next
|
|
1151
|
-
* `LookaheadCache(...)` constructor reads them back. Mirrors the
|
|
1152
|
-
* SimpleCache "one-instance-per-directory" invariant captured in
|
|
1153
|
-
* NOTES.md.
|
|
1154
|
-
*/
|
|
1155
|
-
private fun destroyPlayerKeepCacheOnDisk() {
|
|
1156
|
-
releasePlayerAndState(notifyTrackChange = true)
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
1360
|
/**
|
|
1160
1361
|
* Lifecycle destroy body. Releases the ExoPlayer instance and
|
|
1161
1362
|
* resets every piece of mutable state so a subsequent configure()
|
|
@@ -1171,7 +1372,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1171
1372
|
// player.
|
|
1172
1373
|
cancelPendingRebind()
|
|
1173
1374
|
serviceBinder?.setCommandHandler(null)
|
|
1174
|
-
releasePlayerAndState(
|
|
1375
|
+
releasePlayerAndState()
|
|
1175
1376
|
boundContext?.let { ctx ->
|
|
1176
1377
|
try {
|
|
1177
1378
|
ctx.unbindService(serviceConnection)
|
|
@@ -1200,25 +1401,23 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1200
1401
|
pendingStateChangeReason = null
|
|
1201
1402
|
pendingTrackChangeReason = null
|
|
1202
1403
|
lastErrorKey = null
|
|
1404
|
+
reportedFailureIndices.clear()
|
|
1405
|
+
cancelPendingRetry()
|
|
1203
1406
|
retryAttemptsRemaining.clear()
|
|
1204
1407
|
if (current === this) current = null
|
|
1205
1408
|
}
|
|
1206
1409
|
|
|
1207
1410
|
/**
|
|
1208
|
-
*
|
|
1209
|
-
* state
|
|
1210
|
-
*
|
|
1211
|
-
* cannot
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
* `(null, -1, QUEUE_REPLACED)` so JS hooks like `useActiveTrack`
|
|
1217
|
-
* clear their snapshot — without it a Player UI rendering
|
|
1218
|
-
* `#${index+1}/${queueLength}` would show `#1/0` after the queue
|
|
1219
|
-
* empties.
|
|
1411
|
+
* Tear-down for the writer, the cache reference and per-position
|
|
1412
|
+
* state, called from [destroyInternal]. The writer's coroutine scope
|
|
1413
|
+
* is cancelled before the cache's MIME store is cleared so a racing
|
|
1414
|
+
* `recordMimeType` cannot land after the clear. The Media3 SimpleCache
|
|
1415
|
+
* is a per-folder process singleton and is not released here —
|
|
1416
|
+
* `LookaheadCache.release()` drops only per-instance state; an
|
|
1417
|
+
* eviction-policy change in [configureInternal] releases and rebuilds
|
|
1418
|
+
* it via [discardCacheDirectory].
|
|
1220
1419
|
*/
|
|
1221
|
-
private fun releasePlayerAndState(
|
|
1420
|
+
private fun releasePlayerAndState() {
|
|
1222
1421
|
// Cancel any armed sleep timer up front — the engine (and its volume) is
|
|
1223
1422
|
// going away, so don't touch it or emit; just stop the tick + reset state.
|
|
1224
1423
|
stopSleepTimerTick()
|
|
@@ -1228,16 +1427,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1228
1427
|
tearDownEventObservers()
|
|
1229
1428
|
lookaheadCacheWriter?.release()
|
|
1230
1429
|
lookaheadCacheWriter = null
|
|
1430
|
+
// Before dropping the reference: the evictor is process-wide and outlives
|
|
1431
|
+
// this player, so its protected set has to be cleared here or those entries
|
|
1432
|
+
// stay unevictable until the next configure moves the window.
|
|
1433
|
+
// `LookaheadCache.release()` does not touch it.
|
|
1434
|
+
lookaheadCache?.setProtectedUrls(emptySet())
|
|
1231
1435
|
lookaheadCache?.release()
|
|
1232
1436
|
lookaheadCache = null
|
|
1437
|
+
cellularTransportMonitor?.stop()
|
|
1438
|
+
cellularTransportMonitor = null
|
|
1233
1439
|
// Mark unconfigured so the [player] accessor returns null even
|
|
1234
1440
|
// though the service may still be bound (it unbinds at the
|
|
1235
1441
|
// bottom of destroyInternal). The PlaybackService releases its
|
|
1236
1442
|
// own engine (which owns the ExoPlayer instances) on service
|
|
1237
1443
|
// unbind / onDestroy.
|
|
1238
1444
|
configured = false
|
|
1239
|
-
val hadTracks = tracks.isNotEmpty()
|
|
1240
1445
|
tracks = emptyList()
|
|
1446
|
+
// The clear emits nothing, so a mirror built from `onQueueChange` deltas
|
|
1447
|
+
// would splice the next delta onto the queue this just dropped. Moving
|
|
1448
|
+
// the revision past the next emit's value is what a subscriber reads as
|
|
1449
|
+
// a missed event, and re-reads.
|
|
1450
|
+
queueRevision += 1
|
|
1241
1451
|
currentTrackIndex = -1
|
|
1242
1452
|
currentTrackSource = null
|
|
1243
1453
|
currentBufferState = BufferState.EMPTY
|
|
@@ -1247,9 +1457,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1247
1457
|
hasStartedPlaying = false
|
|
1248
1458
|
wantsToPlay = false
|
|
1249
1459
|
lastReportedState = PlayerState.NONE
|
|
1250
|
-
if (notifyTrackChange && hadTracks) {
|
|
1251
|
-
trackChangeListeners.forEach { it(null, -1.0, TrackChangeReason.QUEUE_REPLACED, null) }
|
|
1252
|
-
}
|
|
1253
1460
|
refreshNowPlayingFormatForActiveItem()
|
|
1254
1461
|
recomputeCapabilities()
|
|
1255
1462
|
}
|
|
@@ -1296,13 +1503,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1296
1503
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
1297
1504
|
return body()
|
|
1298
1505
|
}
|
|
1299
|
-
val
|
|
1506
|
+
val done = CountDownLatch(1)
|
|
1300
1507
|
var result: Result<T>? = null
|
|
1301
1508
|
mainHandler.post {
|
|
1302
1509
|
result = runCatching { body() }
|
|
1303
|
-
|
|
1510
|
+
done.countDown()
|
|
1304
1511
|
}
|
|
1305
|
-
|
|
1512
|
+
done.await()
|
|
1306
1513
|
return result!!.getOrThrow()
|
|
1307
1514
|
}
|
|
1308
1515
|
|
|
@@ -1418,6 +1625,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1418
1625
|
if (trackIndex < 0 || trackIndex >= engine.allMediaItems.size) return@onMainSync
|
|
1419
1626
|
currentTrackIndex = trackIndex
|
|
1420
1627
|
engine.seekTo(trackIndex, maxOf(0L, positionMs))
|
|
1628
|
+
// The index drifted throughout the cast session without touching the
|
|
1629
|
+
// local prefetch or protection windows, so both are stale for the track
|
|
1630
|
+
// local playback is about to resume on.
|
|
1631
|
+
rescheduleLookahead()
|
|
1632
|
+
recomputeCapabilities()
|
|
1421
1633
|
}
|
|
1422
1634
|
}
|
|
1423
1635
|
|
|
@@ -1462,7 +1674,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1462
1674
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
|
|
1463
1675
|
rescheduleLookahead()
|
|
1464
1676
|
recomputeCapabilities()
|
|
1465
|
-
emitQueueChange(
|
|
1677
|
+
emitQueueChange(
|
|
1678
|
+
QueueChangeReason.SET_QUEUE,
|
|
1679
|
+
inserted = tracks.toTypedArray(),
|
|
1680
|
+
insertedAt = if (tracks.isEmpty()) -1 else 0
|
|
1681
|
+
)
|
|
1466
1682
|
}
|
|
1467
1683
|
|
|
1468
1684
|
/**
|
|
@@ -1492,6 +1708,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1492
1708
|
// last item must not suppress an identical coded error on the
|
|
1493
1709
|
// new queue's first item.
|
|
1494
1710
|
lastErrorKey = null
|
|
1711
|
+
reportedFailureIndices.clear()
|
|
1712
|
+
cancelPendingRetry()
|
|
1495
1713
|
// Reset auto-retry counters: new queue → new budget per index.
|
|
1496
1714
|
val attempts = effectiveAutoRetries()
|
|
1497
1715
|
retryAttemptsRemaining =
|
|
@@ -1515,19 +1733,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1515
1733
|
*/
|
|
1516
1734
|
@MainThread
|
|
1517
1735
|
internal fun applyResolvedAssistantQueue(
|
|
1518
|
-
newTracks: List<TrackItem
|
|
1736
|
+
newTracks: List<TrackItem>,
|
|
1737
|
+
append: Boolean = false
|
|
1519
1738
|
): List<MediaItem> {
|
|
1520
|
-
|
|
1521
|
-
|
|
1739
|
+
// `onAddMediaItems` means Media3 will APPEND what this returns, so the
|
|
1740
|
+
// lib's queue has to grow by the same amount. Replacing it here would
|
|
1741
|
+
// leave the player holding old + new while getQueue() reported only the
|
|
1742
|
+
// new tracks, and every index operation after that maps against the
|
|
1743
|
+
// wrong list. `onSetMediaItems` is a genuine replace and keeps that.
|
|
1744
|
+
// Media3 sets the resolved items on the session player after this returns,
|
|
1745
|
+
// which under crossfade addresses the leading leg only. An armed standby
|
|
1746
|
+
// leg still holds the queue as it stood when it was armed, so promoting it
|
|
1747
|
+
// would drop everything added here and auto-advance would stop at the old
|
|
1748
|
+
// queue end. Settle the fade first; the next boundary re-arms from the
|
|
1749
|
+
// updated queue. `CrossfadeEngine.addMediaItems` does this for the paths
|
|
1750
|
+
// that do go through the engine — this is the path that cannot.
|
|
1751
|
+
serviceBinder?.engine?.settlePendingFade()
|
|
1752
|
+
val resolved = if (append) this.tracks + newTracks else newTracks
|
|
1753
|
+
val appendAt = this.tracks.size
|
|
1754
|
+
val previousIndex = this.currentTrackIndex
|
|
1755
|
+
val items = applyTrackListMetadata(resolved) ?: return emptyList()
|
|
1756
|
+
if (append) {
|
|
1757
|
+
// An append does not move the listener off what is playing.
|
|
1758
|
+
this.currentTrackIndex = previousIndex
|
|
1759
|
+
pendingTrackChangeReason = null
|
|
1760
|
+
}
|
|
1761
|
+
serviceBinder?.engine?.setWakeMode(pickWakeMode(resolved))
|
|
1522
1762
|
rescheduleLookahead()
|
|
1523
1763
|
recomputeCapabilities()
|
|
1524
|
-
|
|
1525
|
-
|
|
1764
|
+
// The tracks came from a voice / Android Auto request, so a subscriber has
|
|
1765
|
+
// no other way to learn them — carry them either way.
|
|
1766
|
+
emitQueueChange(
|
|
1767
|
+
if (append) QueueChangeReason.ADD else QueueChangeReason.SET_QUEUE,
|
|
1768
|
+
inserted = if (append) resolved.drop(appendAt).toTypedArray() else resolved.toTypedArray(),
|
|
1769
|
+
insertedAt = if (append) appendAt else if (resolved.isEmpty()) -1 else 0
|
|
1770
|
+
)
|
|
1771
|
+
// Media3 appends only what it is handed back, so return the added slice.
|
|
1772
|
+
return if (append) items.drop(appendAt) else items
|
|
1526
1773
|
}
|
|
1527
1774
|
|
|
1528
1775
|
override fun addToQueue(tracks: Array<TrackItem>, insertBefore: Double?): Promise<Unit> =
|
|
1529
1776
|
Promise.async {
|
|
1530
|
-
// Mirror `seekToInternal
|
|
1777
|
+
// Mirror `seekToInternal`'s non-finite guard: NaN/±Infinity
|
|
1531
1778
|
// from JS would `.toInt()` to MIN/MAX or 0 silently. When
|
|
1532
1779
|
// insertBefore is non-finite, drop it (treat as "append at
|
|
1533
1780
|
// end") rather than silently jumping to position 0.
|
|
@@ -1570,6 +1817,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1570
1817
|
val ctx = boundContext ?: return -1
|
|
1571
1818
|
|
|
1572
1819
|
val at = QueueMutationArithmetic.clampInsertBefore(insertBefore, tracks.size)
|
|
1820
|
+
// A mutation renumbers positions, so every recorded index but the current
|
|
1821
|
+
// track's is stale afterwards. The one that matters is the stop on the
|
|
1822
|
+
// track being heard: it follows the index through the mutation, and the
|
|
1823
|
+
// rest go rather than being carried onto whatever now sits at their number.
|
|
1824
|
+
val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
|
|
1573
1825
|
this.tracks = tracks.toMutableList().apply { addAll(at, newTracks) }
|
|
1574
1826
|
this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
|
|
1575
1827
|
currentIndex = currentTrackIndex,
|
|
@@ -1578,10 +1830,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1578
1830
|
)
|
|
1579
1831
|
|
|
1580
1832
|
// Index-keyed retry-budget map: insertion at `at` shifts every
|
|
1581
|
-
// existing key >= at up by `newTracks.size`.
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1833
|
+
// existing key >= at up by `newTracks.size`. The shift must run in
|
|
1834
|
+
// descending key order: each iteration removes `oldIdx` and writes
|
|
1835
|
+
// `oldIdx + shift`, and in ascending order the write for key k lands
|
|
1836
|
+
// on k + shift, and the later iteration for k + shift removes it, so
|
|
1837
|
+
// that budget is lost.
|
|
1838
|
+
cancelPendingRetry()
|
|
1585
1839
|
val attempts = effectiveAutoRetries()
|
|
1586
1840
|
val shift = newTracks.size
|
|
1587
1841
|
val shifted = retryAttemptsRemaining
|
|
@@ -1594,6 +1848,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1594
1848
|
for (newIdx in at until at + shift) {
|
|
1595
1849
|
retryAttemptsRemaining[newIdx] = attempts
|
|
1596
1850
|
}
|
|
1851
|
+
reportedFailureIndices.clear()
|
|
1852
|
+
if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
|
|
1597
1853
|
val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
|
|
1598
1854
|
serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
|
|
1599
1855
|
// Diverges from iOS: add-into-empty-queue on iOS leaves the
|
|
@@ -1614,7 +1870,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1614
1870
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
|
|
1615
1871
|
rescheduleLookahead()
|
|
1616
1872
|
recomputeCapabilities()
|
|
1617
|
-
emitQueueChange(
|
|
1873
|
+
emitQueueChange(
|
|
1874
|
+
QueueChangeReason.ADD,
|
|
1875
|
+
inserted = newTracks.toTypedArray(),
|
|
1876
|
+
insertedAt = at
|
|
1877
|
+
)
|
|
1618
1878
|
return at
|
|
1619
1879
|
}
|
|
1620
1880
|
|
|
@@ -1660,6 +1920,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1660
1920
|
newTracks.removeAt(i)
|
|
1661
1921
|
}
|
|
1662
1922
|
this.tracks = newTracks
|
|
1923
|
+
// A mutation renumbers positions, so every recorded index but the current
|
|
1924
|
+
// track's is stale afterwards. The one that matters is the stop on the
|
|
1925
|
+
// track being heard: it follows the index through the mutation, and the
|
|
1926
|
+
// rest go rather than being carried onto whatever now sits at their number.
|
|
1927
|
+
val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
|
|
1663
1928
|
this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
|
|
1664
1929
|
currentIndex = oldCur,
|
|
1665
1930
|
sanitisedIndices = sanitised,
|
|
@@ -1698,9 +1963,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1698
1963
|
// - **Why not preserve via remap**: requires walking the map
|
|
1699
1964
|
// AND the sanitised-indices in lockstep with offset
|
|
1700
1965
|
// accounting. The clear-and-repopulate is intentional.
|
|
1966
|
+
cancelPendingRetry()
|
|
1701
1967
|
val attempts = effectiveAutoRetries()
|
|
1702
1968
|
retryAttemptsRemaining.clear()
|
|
1703
1969
|
for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
|
|
1970
|
+
reportedFailureIndices.clear()
|
|
1971
|
+
if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
|
|
1704
1972
|
// Mirror the wake-mode hook on `setQueueInternal` /
|
|
1705
1973
|
// `addToQueueInternal`: removing the last remote URL out of a
|
|
1706
1974
|
// mixed queue must downgrade to LOCAL so the WiFi wake lock
|
|
@@ -1710,7 +1978,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1710
1978
|
recomputeCapabilities()
|
|
1711
1979
|
// Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
|
|
1712
1980
|
// removed; tracks + index are final.
|
|
1713
|
-
emitQueueChange(QueueChangeReason.REMOVE)
|
|
1981
|
+
emitQueueChange(QueueChangeReason.REMOVE, removed = sanitised)
|
|
1714
1982
|
return sanitised
|
|
1715
1983
|
}
|
|
1716
1984
|
|
|
@@ -1721,7 +1989,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1721
1989
|
// mirror the reorder to the receiver. `routeMoveInQueue` is
|
|
1722
1990
|
// suspending so it runs outside `onMain`. Mirrors iOS `moveInQueue`.
|
|
1723
1991
|
val move = onMain {
|
|
1724
|
-
// Mirror `seekToInternal
|
|
1992
|
+
// Mirror `seekToInternal`'s non-finite guard.
|
|
1725
1993
|
if (!fromIndex.isFinite() || !toIndex.isFinite()) return@onMain null
|
|
1726
1994
|
val from = fromIndex.toInt()
|
|
1727
1995
|
val to = toIndex.toInt()
|
|
@@ -1758,6 +2026,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1758
2026
|
mutable.add(to, moved)
|
|
1759
2027
|
this.tracks = mutable
|
|
1760
2028
|
|
|
2029
|
+
// A mutation renumbers positions, so every recorded index but the current
|
|
2030
|
+
// track's is stale afterwards. The one that matters is the stop on the
|
|
2031
|
+
// track being heard: it follows the index through the mutation, and the
|
|
2032
|
+
// rest go rather than being carried onto whatever now sits at their number.
|
|
2033
|
+
val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
|
|
1761
2034
|
this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
|
|
1762
2035
|
currentIndex = currentTrackIndex,
|
|
1763
2036
|
fromIndex = from,
|
|
@@ -1771,19 +2044,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1771
2044
|
// arbitrarily. Cheapest correct path is to clear + repopulate
|
|
1772
2045
|
// (same trade-off as removeFromQueueInternal — structural reset
|
|
1773
2046
|
// semantics for queue mutations).
|
|
2047
|
+
cancelPendingRetry()
|
|
1774
2048
|
val attempts = effectiveAutoRetries()
|
|
1775
2049
|
retryAttemptsRemaining.clear()
|
|
1776
2050
|
for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
|
|
2051
|
+
reportedFailureIndices.clear()
|
|
2052
|
+
if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
|
|
1777
2053
|
rescheduleLookahead()
|
|
1778
2054
|
recomputeCapabilities()
|
|
1779
2055
|
// Reached only past the invalid / `from == to` / pinned-current
|
|
1780
2056
|
// guards, so the order actually changed.
|
|
1781
|
-
emitQueueChange(QueueChangeReason.MOVE)
|
|
2057
|
+
emitQueueChange(QueueChangeReason.MOVE, movedFrom = from, movedTo = to)
|
|
1782
2058
|
return true
|
|
1783
2059
|
}
|
|
1784
2060
|
|
|
1785
|
-
override fun getQueue(): Array<TrackItem
|
|
1786
|
-
tracks.toTypedArray()
|
|
2061
|
+
override fun getQueue(): Promise<Array<TrackItem>> =
|
|
2062
|
+
Promise.async { onMain { tracks.toTypedArray() } }
|
|
1787
2063
|
|
|
1788
2064
|
override fun clearQueue(): Promise<Unit> =
|
|
1789
2065
|
Promise.async {
|
|
@@ -1812,6 +2088,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1812
2088
|
// Reset error-dedup so a subsequent queue's identical-coded error
|
|
1813
2089
|
// isn't suppressed by a stale entry from the cleared queue.
|
|
1814
2090
|
lastErrorKey = null
|
|
2091
|
+
reportedFailureIndices.clear()
|
|
2092
|
+
cancelPendingRetry()
|
|
1815
2093
|
retryAttemptsRemaining.clear()
|
|
1816
2094
|
tracks = emptyList()
|
|
1817
2095
|
currentTrackIndex = -1
|
|
@@ -1866,18 +2144,47 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1866
2144
|
// engine. Same shape at every transport entry point — see
|
|
1867
2145
|
// `CastTransportRouter` for the early-return contract.
|
|
1868
2146
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
|
|
1869
|
-
// Stuck-recovery: if the user re-taps play after
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
currentTrackIndex >= 0 &&
|
|
1876
|
-
(retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
|
|
1877
|
-
retryAttemptsRemaining[currentTrackIndex] = 1
|
|
2147
|
+
// Stuck-recovery: if the user re-taps play after an exhausted-retries
|
|
2148
|
+
// error, hand the track back the allowance the consumer configured and
|
|
2149
|
+
// re-prepare. Not a hard-coded 1: a consumer who set `autoRetries: 0`
|
|
2150
|
+
// asked for none. Mirrors iOS `play()` stuck-recovery.
|
|
2151
|
+
if (currentTrackIndex >= 0 && currentTrackIndex in reportedFailureIndices) {
|
|
2152
|
+
retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
|
|
1878
2153
|
lastErrorKey = null
|
|
1879
|
-
|
|
2154
|
+
// `retryFailedItem`, not a bare `prepare()`: a bare prepare re-uses
|
|
2155
|
+
// the wedged DataSource this exists to discard. It removes and re-adds
|
|
2156
|
+
// the item, which rebuilds the source from the MediaSource.Factory.
|
|
2157
|
+
retryFailedItem(currentTrackIndex)
|
|
2158
|
+
} else if (currentTrackIndex >= 0 &&
|
|
2159
|
+
player?.playbackState == Player.STATE_IDLE &&
|
|
2160
|
+
(player?.mediaItemCount ?: 0) > 0
|
|
2161
|
+
) {
|
|
2162
|
+
// With no current track there is no position to re-seat, which the
|
|
2163
|
+
// index guard above covers; the branch below refuses to start
|
|
2164
|
+
// anything in that state at all.
|
|
2165
|
+
// A failure leaves ExoPlayer in STATE_IDLE, and a skip off the failed
|
|
2166
|
+
// track seeks without re-preparing — so `play()` would set
|
|
2167
|
+
// `playWhenReady` on a player that never leaves IDLE and nothing would
|
|
2168
|
+
// be heard. `retryFailedItem`, not a bare `prepare()`: on a same-index
|
|
2169
|
+
// skip the item is the one that failed, and a bare prepare re-uses the
|
|
2170
|
+
// DataSource that wedged. The timeline has to hold items for prepare to
|
|
2171
|
+
// reach anything — between `clearQueue` and the next `setMediaItems`
|
|
2172
|
+
// it is empty, and preparing there lands in STATE_ENDED, which the
|
|
2173
|
+
// line below reads as a finished queue.
|
|
2174
|
+
retryFailedItem(currentTrackIndex)
|
|
1880
2175
|
}
|
|
2176
|
+
// At the end of the queue there is nothing to play: ExoPlayer stays
|
|
2177
|
+
// STATE_ENDED through play(), and a playWhenReady left set would
|
|
2178
|
+
// start the next track a skip seats without anyone asking. A skip, a
|
|
2179
|
+
// seek or a fresh queue leaves this state; play() alone restarts
|
|
2180
|
+
// nothing — the same contract as iOS.
|
|
2181
|
+
if (player?.playbackState == Player.STATE_ENDED && tracks.isNotEmpty()) return@onMain
|
|
2182
|
+
// Nothing is seated: an add into an empty queue primes the player and
|
|
2183
|
+
// deliberately leaves the current index at -1, so starting here would
|
|
2184
|
+
// play position 0 while every read still answers for no track. The
|
|
2185
|
+
// consumer seats one — `skipToIndex(0)` — and plays. iOS reaches the
|
|
2186
|
+
// same outcome by leaving its queue player empty until then.
|
|
2187
|
+
if (currentTrackIndex < 0) return@onMain
|
|
1881
2188
|
// Stash USER on the pending-reason channel so the
|
|
1882
2189
|
// subsequent Player.Listener fire attributes the transition
|
|
1883
2190
|
// correctly.
|
|
@@ -1894,17 +2201,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1894
2201
|
// During cast the receiver owns playback — forward a play so a failed
|
|
1895
2202
|
// receiver item re-attempts.
|
|
1896
2203
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
|
|
1897
|
-
//
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
|
|
1904
|
-
|
|
2204
|
+
// A user asking for a retry has a reason, and the library is not the
|
|
2205
|
+
// one to judge it. This is the manual attempt once the automatic one
|
|
2206
|
+
// is spent, so gating it on the player's state would gate recovery on
|
|
2207
|
+
// the state of a player that has already failed to recover — and a
|
|
2208
|
+
// wedged decoder still reporting PLAYING is exactly when someone
|
|
2209
|
+
// reaches for the button. retryFailedItem below discards the wedged
|
|
2210
|
+
// DataSource (a bare prepare() would re-use it), restores the
|
|
2211
|
+
// pre-failure position, and resumes playing.
|
|
2212
|
+
//
|
|
2213
|
+
// Only the bounds remain: no current track is genuinely nothing to
|
|
2214
|
+
// retry, which is not second-guessing the request.
|
|
2215
|
+
if (currentTrackIndex < 0) return@onMain
|
|
2216
|
+
// The configured budget, not a hard-coded 1: this restores the track's
|
|
2217
|
+
// automatic allowance for whatever fails *next*, and a consumer who
|
|
2218
|
+
// set `autoRetries: 0` asked for none.
|
|
2219
|
+
retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
|
|
1905
2220
|
lastErrorKey = null
|
|
1906
2221
|
pendingStateChangeReason = StateChangeReason.USER
|
|
1907
2222
|
wantsToPlay = true
|
|
2223
|
+
// Drops this track's report on its way through, before its own
|
|
2224
|
+
// current-track bail, so a retry always clears what it attempts.
|
|
1908
2225
|
retryFailedItem(currentTrackIndex)
|
|
1909
2226
|
recomputeBufferState()
|
|
1910
2227
|
}
|
|
@@ -2108,7 +2425,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2108
2425
|
override fun skipToIndex(index: Double): Promise<Unit> =
|
|
2109
2426
|
Promise.async {
|
|
2110
2427
|
onMain {
|
|
2111
|
-
// Mirror `seekToInternal
|
|
2428
|
+
// Mirror `seekToInternal`'s non-finite guard. Without this
|
|
2112
2429
|
// `Double.NaN.toInt()` returns 0 — `skipToIndex(NaN)` would
|
|
2113
2430
|
// silently jump to track 0 instead of no-op'ing. iOS uses
|
|
2114
2431
|
// `InputGuards.validQueueIndex` for the same guarantee.
|
|
@@ -2139,6 +2456,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2139
2456
|
val p = player ?: return
|
|
2140
2457
|
if (target < 0 || target >= tracks.size) return
|
|
2141
2458
|
currentTrackIndex = target
|
|
2459
|
+
// Seating a position is an attempt at it, so the report against it no
|
|
2460
|
+
// longer describes what is about to play. Cleared here rather than left to
|
|
2461
|
+
// the media-item transition, which does not fire for a skip that seats the
|
|
2462
|
+
// track already selected. Mirrors iOS `applyNewCurrentIndex`.
|
|
2463
|
+
reportedFailureIndices.remove(target)
|
|
2142
2464
|
pendingTrackChangeReason = TrackChangeReason.USER_SKIP_TO_INDEX
|
|
2143
2465
|
// Guard against IllegalSeekPositionException: `seekTo(index, 0L)`
|
|
2144
2466
|
// throws when `index` is outside `mediaItemCount`. When the lib
|
|
@@ -2166,6 +2488,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2166
2488
|
val p = player ?: return
|
|
2167
2489
|
val next = computeNextIndex() ?: return
|
|
2168
2490
|
currentTrackIndex = next
|
|
2491
|
+
reportedFailureIndices.remove(next)
|
|
2169
2492
|
pendingTrackChangeReason = TrackChangeReason.USER_SKIP_NEXT
|
|
2170
2493
|
if (next < p.mediaItemCount) {
|
|
2171
2494
|
serviceBinder?.engine?.seekTo(next, 0L)
|
|
@@ -2204,6 +2527,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2204
2527
|
is QueueSkipArithmetic.SkipToPreviousAction.Previous -> {
|
|
2205
2528
|
val prev = action.index
|
|
2206
2529
|
currentTrackIndex = prev
|
|
2530
|
+
reportedFailureIndices.remove(prev)
|
|
2207
2531
|
pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
|
|
2208
2532
|
if (prev < p.mediaItemCount) {
|
|
2209
2533
|
serviceBinder?.engine?.seekTo(prev, 0L)
|
|
@@ -2218,6 +2542,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2218
2542
|
// untouched (stashing a reason no transition consumes would leak
|
|
2219
2543
|
// into the next real skip).
|
|
2220
2544
|
if (currentTrackIndex in 0 until p.mediaItemCount) {
|
|
2545
|
+
reportedFailureIndices.remove(currentTrackIndex)
|
|
2221
2546
|
serviceBinder?.engine?.seekTo(currentTrackIndex, 0L)
|
|
2222
2547
|
}
|
|
2223
2548
|
}
|
|
@@ -2240,14 +2565,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2240
2565
|
repeatMode = repeatModeAsSkipMode()
|
|
2241
2566
|
)
|
|
2242
2567
|
|
|
2243
|
-
@VisibleForTesting
|
|
2244
|
-
internal fun computePreviousIndex(): Int? =
|
|
2245
|
-
QueueSkipArithmetic.computePrevious(
|
|
2246
|
-
trackCount = tracks.size,
|
|
2247
|
-
currentIndex = currentTrackIndex,
|
|
2248
|
-
repeatMode = repeatModeAsSkipMode()
|
|
2249
|
-
)
|
|
2250
|
-
|
|
2251
2568
|
private fun repeatModeAsSkipMode(): QueueSkipRepeatMode = when (repeatModeState) {
|
|
2252
2569
|
RepeatMode.OFF -> QueueSkipRepeatMode.OFF
|
|
2253
2570
|
RepeatMode.TRACK -> QueueSkipRepeatMode.TRACK
|
|
@@ -2279,7 +2596,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2279
2596
|
* clearQueueInternal, skipToIndexInternal / skipToNextInternal /
|
|
2280
2597
|
* skipToPreviousInternal (all index writes), setRepeatModeInternal,
|
|
2281
2598
|
* shuffleQueueInternal, handleMediaItemTransition (covers Media3
|
|
2282
|
-
* REASON_AUTO + REASON_REPEAT auto-advance),
|
|
2599
|
+
* REASON_AUTO + REASON_REPEAT auto-advance), the crossfade
|
|
2600
|
+
* fade-start / cancel index writes, emitRemoteTrackChange and
|
|
2601
|
+
* resumeLocalAfterCast (cast), destroyInternal.
|
|
2283
2602
|
*/
|
|
2284
2603
|
@MainThread
|
|
2285
2604
|
internal fun recomputeCapabilities() {
|
|
@@ -2470,6 +2789,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2470
2789
|
newEngine.setVolume(volumeState)
|
|
2471
2790
|
newEngine.setPlaybackSpeed(playbackSpeedState)
|
|
2472
2791
|
newEngine.setPitchCorrectionMode(pitchCorrectionModeState)
|
|
2792
|
+
installSuppliedReplayGainResolver(newEngine)
|
|
2473
2793
|
// Drive the AirPlay receiver-display metadata sync. When the new engine is
|
|
2474
2794
|
// the dedicated AirPlay engine, hand the now-active session this engine's
|
|
2475
2795
|
// ExoPlayer — registered in AirPlayEngine.init, so CastSinkRouter.player()
|
|
@@ -2489,7 +2809,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2489
2809
|
override fun setPlaybackSpeed(rate: Double): Promise<Unit> =
|
|
2490
2810
|
Promise.async {
|
|
2491
2811
|
onMain {
|
|
2492
|
-
// Mirror `seekToInternal
|
|
2812
|
+
// Mirror `seekToInternal`'s non-finite guard. NaN →
|
|
2493
2813
|
// undefined ExoPlayer audio behaviour at the AudioSink;
|
|
2494
2814
|
// ±Infinity → IllegalArgumentException from
|
|
2495
2815
|
// `PlaybackParameters` constructor.
|
|
@@ -2537,7 +2857,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2537
2857
|
override fun setVolume(volume: Double): Promise<Unit> =
|
|
2538
2858
|
Promise.async {
|
|
2539
2859
|
onMain {
|
|
2540
|
-
// Mirror `seekToInternal
|
|
2860
|
+
// Mirror `seekToInternal`'s non-finite guard. NaN →
|
|
2541
2861
|
// undefined audio output; ±Infinity → ditto.
|
|
2542
2862
|
if (!volume.isFinite()) return@onMain
|
|
2543
2863
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetVolume(volume.toFloat())) return@onMain
|
|
@@ -2840,16 +3160,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2840
3160
|
// resume after the rebuild.
|
|
2841
3161
|
val wasPlaying = p.isPlaying
|
|
2842
3162
|
|
|
2843
|
-
//
|
|
2844
|
-
|
|
3163
|
+
// Shuffle positions rather than tracks: the permutation is what
|
|
3164
|
+
// onQueueChange reports, and it cannot be recovered from the reordered
|
|
3165
|
+
// list afterwards. Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
|
|
3166
|
+
val order = tracks.indices.shuffled()
|
|
3167
|
+
val newTracks = order.map { tracks[it] }
|
|
2845
3168
|
|
|
2846
3169
|
this.tracks = newTracks
|
|
2847
3170
|
this.currentTrackIndex = 0
|
|
2848
3171
|
pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
|
|
2849
3172
|
// Destructive shuffle replaces every position; reset retry budget.
|
|
3173
|
+
cancelPendingRetry()
|
|
2850
3174
|
val attempts = effectiveAutoRetries()
|
|
2851
3175
|
retryAttemptsRemaining.clear()
|
|
2852
3176
|
for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
|
|
3177
|
+
// The permutation decides what lands at position 0, so the track the queue
|
|
3178
|
+
// stopped on is not the one the index now names. The stop is dropped with
|
|
3179
|
+
// the retry budget rather than carried onto whichever track was moved to
|
|
3180
|
+
// the front.
|
|
3181
|
+
reportedFailureIndices.clear()
|
|
2853
3182
|
|
|
2854
3183
|
val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
|
|
2855
3184
|
serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
|
|
@@ -2864,7 +3193,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2864
3193
|
rescheduleLookahead()
|
|
2865
3194
|
recomputeCapabilities()
|
|
2866
3195
|
// Skip a single-track shuffle — the order can't change.
|
|
2867
|
-
if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
|
|
3196
|
+
if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE, order = order)
|
|
2868
3197
|
|
|
2869
3198
|
return ShuffleResult(
|
|
2870
3199
|
tracks = newTracks.toTypedArray(),
|
|
@@ -2920,11 +3249,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2920
3249
|
)
|
|
2921
3250
|
}
|
|
2922
3251
|
|
|
2923
|
-
// No `syncMain` hop on the next
|
|
3252
|
+
// No `syncMain` hop on the next five getters: each reads a single
|
|
2924
3253
|
// `@Volatile` field that is only written on main, so the JVM
|
|
2925
3254
|
// memory model gives the cross-thread happens-before edge for
|
|
2926
|
-
// free. iOS
|
|
2927
|
-
//
|
|
3255
|
+
// free. iOS hops through `onPlayerQueueSync` instead: its state is
|
|
3256
|
+
// confined to `playerQueue`, and Swift has no `@Volatile`-equivalent
|
|
3257
|
+
// storage class.
|
|
2928
3258
|
override fun getCurrentTrackIndex(): Double = currentTrackIndex.toDouble()
|
|
2929
3259
|
|
|
2930
3260
|
override fun getCurrentTrackSource(): TrackSource? = currentTrackSource
|
|
@@ -2968,26 +3298,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2968
3298
|
}
|
|
2969
3299
|
|
|
2970
3300
|
override fun onQueueChange(
|
|
2971
|
-
callback: (
|
|
3301
|
+
callback: (delta: QueueChangeDelta, currentIndex: Double, reason: QueueChangeReason) -> Unit
|
|
2972
3302
|
): () -> Unit {
|
|
2973
3303
|
val id = queueChangeListeners.add(callback)
|
|
2974
3304
|
return { queueChangeListeners.remove(id) }
|
|
2975
3305
|
}
|
|
2976
3306
|
|
|
2977
3307
|
/**
|
|
2978
|
-
*
|
|
2979
|
-
* queue
|
|
2980
|
-
*
|
|
2981
|
-
*
|
|
2982
|
-
*
|
|
3308
|
+
* Emit a queue change as a description of what happened rather than a copy of
|
|
3309
|
+
* the queue. Callers pass only the fields their mutation defines; the rest
|
|
3310
|
+
* stay empty, and `revision` lets a subscriber notice a dropped event.
|
|
3311
|
+
* Main-thread only — every queue mutation commits on main, so [tracks] /
|
|
3312
|
+
* [currentTrackIndex] are settled here. Callers gate this on an actual
|
|
3313
|
+
* content/order change so a no-op mutation never emits.
|
|
2983
3314
|
*/
|
|
2984
3315
|
@MainThread
|
|
2985
|
-
private fun emitQueueChange(
|
|
2986
|
-
|
|
3316
|
+
private fun emitQueueChange(
|
|
3317
|
+
reason: QueueChangeReason,
|
|
3318
|
+
inserted: Array<TrackItem> = emptyArray(),
|
|
3319
|
+
insertedAt: Int = -1,
|
|
3320
|
+
removed: List<Int> = emptyList(),
|
|
3321
|
+
movedFrom: Int = -1,
|
|
3322
|
+
movedTo: Int = -1,
|
|
3323
|
+
order: List<Int> = emptyList()
|
|
3324
|
+
) {
|
|
3325
|
+
queueRevision += 1
|
|
3326
|
+
val delta = QueueChangeDelta(
|
|
3327
|
+
revision = queueRevision.toDouble(),
|
|
3328
|
+
length = tracks.size.toDouble(),
|
|
3329
|
+
inserted = inserted,
|
|
3330
|
+
insertedAt = insertedAt.toDouble(),
|
|
3331
|
+
removed = removed.map { it.toDouble() }.toDoubleArray(),
|
|
3332
|
+
movedFrom = movedFrom.toDouble(),
|
|
3333
|
+
movedTo = movedTo.toDouble(),
|
|
3334
|
+
order = order.map { it.toDouble() }.toDoubleArray()
|
|
3335
|
+
)
|
|
2987
3336
|
val index = currentTrackIndex.toDouble()
|
|
2988
|
-
queueChangeListeners.forEach { it(
|
|
3337
|
+
queueChangeListeners.forEach { it(delta, index, reason) }
|
|
2989
3338
|
}
|
|
2990
3339
|
|
|
3340
|
+
/** Increments once per queue-change emit; a gap tells a subscriber it missed one. */
|
|
3341
|
+
private var queueRevision: Long = 0
|
|
3342
|
+
|
|
2991
3343
|
override fun onProgress(
|
|
2992
3344
|
callback: (progress: PlayerProgress) -> Unit
|
|
2993
3345
|
): () -> Unit {
|
|
@@ -3149,15 +3501,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3149
3501
|
* drained). Post-bind: registers immediately. Either way the
|
|
3150
3502
|
* returned disposer matches the consumer's expectation that
|
|
3151
3503
|
* calling it tears the registration down regardless of timing.
|
|
3152
|
-
*
|
|
3153
|
-
*
|
|
3154
|
-
*
|
|
3155
|
-
*
|
|
3156
|
-
*
|
|
3157
|
-
* every registration; the carService thought it had wired up but
|
|
3158
|
-
* the lib had nothing recorded, so Android Auto's later bind hit
|
|
3159
|
-
* `dispatchCarConnect` with a null callback + timed out → AA
|
|
3160
|
-
* "No items".
|
|
3504
|
+
* `registerPlaybackService` may run before `configure()` resolves, and
|
|
3505
|
+
* [serviceBinder] is null again between an OS service kill and the
|
|
3506
|
+
* rebind (`onServiceDisconnected`); both cases queue here and are
|
|
3507
|
+
* applied by [drainPendingBrowseRegistrations] on the next
|
|
3508
|
+
* `onServiceConnected`.
|
|
3161
3509
|
*/
|
|
3162
3510
|
private fun deferredRegister(
|
|
3163
3511
|
register: (BrowseCallbackRegistry) -> () -> Unit
|
|
@@ -3203,14 +3551,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3203
3551
|
}
|
|
3204
3552
|
}
|
|
3205
3553
|
|
|
3206
|
-
|
|
3207
|
-
|
|
3554
|
+
/**
|
|
3555
|
+
* Asynchronous because the snapshot counts fully-cached entries, which walks
|
|
3556
|
+
* every cache key and takes the cache's monitor per key — contended against
|
|
3557
|
+
* the prefetch thread. Resolved off both the JS thread and the app looper;
|
|
3558
|
+
* every field it reads is published for that.
|
|
3559
|
+
*/
|
|
3560
|
+
override fun getLookaheadCacheStatus(): Promise<CacheStatus> =
|
|
3561
|
+
Promise.async { buildCacheStatusSnapshot() }
|
|
3208
3562
|
|
|
3563
|
+
/**
|
|
3564
|
+
* Wipe the cache and resolve once it is actually gone.
|
|
3565
|
+
*
|
|
3566
|
+
* The delete is file IO, so it runs off the app looper; the promise resolves
|
|
3567
|
+
* after it, because a consumer that awaits this and then reads
|
|
3568
|
+
* `getLookaheadCacheStatus()` must not see the pre-wipe size.
|
|
3569
|
+
*/
|
|
3209
3570
|
override fun clearLookaheadCache(): Promise<Unit> =
|
|
3210
|
-
Promise.async {
|
|
3571
|
+
Promise.async {
|
|
3572
|
+
onMain { clearLookaheadCacheInternal() }?.join()
|
|
3573
|
+
onMain { emitCacheStatus() }
|
|
3574
|
+
}
|
|
3211
3575
|
|
|
3212
3576
|
override fun onCacheStatusChange(callback: (status: CacheStatus) -> Unit): () -> Unit {
|
|
3213
3577
|
val id = cacheStatusListeners.add(callback)
|
|
3578
|
+
// Deliver the current status once. Otherwise a settings screen shows its
|
|
3579
|
+
// placeholder until the next prefetch completes — which never happens for a
|
|
3580
|
+
// fully-cached queue or a consumer running with prefetch off.
|
|
3581
|
+
cacheIoScope.launch(cacheStatusDispatcher) {
|
|
3582
|
+
val snapshot = buildCacheStatusSnapshot()
|
|
3583
|
+
mainHandler.post {
|
|
3584
|
+
if (cacheStatusListeners.contains(id)) callback(snapshot)
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3214
3587
|
return { cacheStatusListeners.remove(id) }
|
|
3215
3588
|
}
|
|
3216
3589
|
|
|
@@ -3267,6 +3640,30 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3267
3640
|
|
|
3268
3641
|
override fun getReplayGainMode(): ReplayGainMode = ReplayGainConfig.getMode()
|
|
3269
3642
|
|
|
3643
|
+
/**
|
|
3644
|
+
* Hand a freshly-installed engine the reader for consumer-supplied
|
|
3645
|
+
* ReplayGain. The engine holds no queue metadata of its own — it
|
|
3646
|
+
* plays `MediaItem`s — so the lookup from a queue index back to the
|
|
3647
|
+
* consumer's `TrackItem` lives here, where the canonical [tracks]
|
|
3648
|
+
* list does.
|
|
3649
|
+
*
|
|
3650
|
+
* The lambda reads [tracks] on every invocation rather than closing
|
|
3651
|
+
* over a snapshot, so a `setQueue` carrying corrected values is
|
|
3652
|
+
* picked up by the next recompute. Called wherever an engine is
|
|
3653
|
+
* installed — the configure-time rebuild and the playback-mode swap —
|
|
3654
|
+
* because a fresh engine starts with no resolver.
|
|
3655
|
+
*/
|
|
3656
|
+
private fun installSuppliedReplayGainResolver(engine: PlaybackEngine?) {
|
|
3657
|
+
val resolver: (Int) -> SuppliedReplayGain? = { index ->
|
|
3658
|
+
tracks.getOrNull(index)?.let { SuppliedReplayGain.from(it) }
|
|
3659
|
+
}
|
|
3660
|
+
when (engine) {
|
|
3661
|
+
is GaplessEngine -> engine.suppliedReplayGainResolver = resolver
|
|
3662
|
+
is CrossfadeEngine -> engine.suppliedReplayGainResolver = resolver
|
|
3663
|
+
else -> Unit
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3270
3667
|
/**
|
|
3271
3668
|
* Re-resolve the active item's format and emit if it differs from
|
|
3272
3669
|
* the last cached value. Call sites: [Player.Listener.onMediaItemTransition]
|
|
@@ -3326,14 +3723,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3326
3723
|
* live — no configure() cycle needed:
|
|
3327
3724
|
*
|
|
3328
3725
|
* - `enabled` toggle:
|
|
3329
|
-
* - true → false: tears down the WRITER (no more proactive
|
|
3330
|
-
*
|
|
3331
|
-
*
|
|
3332
|
-
*
|
|
3333
|
-
*
|
|
3726
|
+
* - true → false: tears down the WRITER (no more proactive prefetch)
|
|
3727
|
+
* and routes the player's reads to the origin from the next item
|
|
3728
|
+
* load; the item already loading keeps the source it opened. The
|
|
3729
|
+
* cache OBJECT stays alive (Media3 allows one per directory) but is
|
|
3730
|
+
* neither read nor written while disabled.
|
|
3334
3731
|
* - false → true: rebuilds the writer against the existing cache
|
|
3335
|
-
* (the cache is built at configure() regardless of `enabled`)
|
|
3336
|
-
* so caching turns on without a
|
|
3732
|
+
* (the cache is built at configure() regardless of `enabled`) and
|
|
3733
|
+
* routes reads through it again, so caching turns on without a
|
|
3734
|
+
* configure() cycle.
|
|
3337
3735
|
*
|
|
3338
3736
|
* - `lookaheadCount` change: live update via
|
|
3339
3737
|
* `LookaheadCacheWriter.defaultLookaheadCount`. Applies on the
|
|
@@ -3355,8 +3753,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3355
3753
|
|
|
3356
3754
|
val countChanged = newConfig.lookaheadCount != previous.lookaheadCount
|
|
3357
3755
|
val enableChanged = newConfig.enabled != previous.enabled
|
|
3358
|
-
|
|
3359
|
-
|
|
3756
|
+
val cellularChanged =
|
|
3757
|
+
(newConfig.allowsCellularAccess ?: true) != (previous.allowsCellularAccess ?: true)
|
|
3758
|
+
|
|
3759
|
+
// The player's reads follow the flag from the next item load: disabled
|
|
3760
|
+
// means the origin, whatever the disk holds. The cache object stays —
|
|
3761
|
+
// Media3 allows one instance per directory — but nothing reads or writes
|
|
3762
|
+
// it until it is enabled again.
|
|
3763
|
+
if (enableChanged) {
|
|
3764
|
+
lookaheadCache?.servesPlayback = newConfig.enabled
|
|
3765
|
+
}
|
|
3766
|
+
// Disable → tear down the writer.
|
|
3360
3767
|
if (enableChanged && !newConfig.enabled) {
|
|
3361
3768
|
lookaheadCacheWriter?.release()
|
|
3362
3769
|
lookaheadCacheWriter = null
|
|
@@ -3377,51 +3784,80 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3377
3784
|
networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
|
|
3378
3785
|
config.networkTimeoutMs
|
|
3379
3786
|
)
|
|
3380
|
-
)
|
|
3787
|
+
),
|
|
3788
|
+
isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
|
|
3381
3789
|
).apply {
|
|
3382
3790
|
defaultLookaheadCount =
|
|
3383
3791
|
newConfig.lookaheadCount.toInt().coerceAtLeast(0)
|
|
3792
|
+
allowsCellularAccess = newConfig.allowsCellularAccess ?: true
|
|
3384
3793
|
onTrackProcessed = { onMainPostStatusEmit() }
|
|
3385
3794
|
}
|
|
3386
3795
|
rescheduleLookahead()
|
|
3387
3796
|
}
|
|
3388
3797
|
}
|
|
3389
3798
|
|
|
3390
|
-
// Live
|
|
3391
|
-
// the
|
|
3392
|
-
//
|
|
3799
|
+
// Live policy update — safe regardless of player lifecycle. `lookaheadCount`
|
|
3800
|
+
// resizes the window; `allowsCellularAccess` decides whether the window is
|
|
3801
|
+
// fetched at all, so turning it off empties it and cancels the prefetch in
|
|
3802
|
+
// flight and turning it back on refills it. Both take effect on the single
|
|
3803
|
+
// reschedule below. Skipped when the enable branch above just rebuilt the
|
|
3804
|
+
// writer, which already applied both values and rescheduled.
|
|
3393
3805
|
val writer = lookaheadCacheWriter
|
|
3394
|
-
if (
|
|
3395
|
-
|
|
3396
|
-
|
|
3806
|
+
if (!enableChanged && writer != null && (countChanged || cellularChanged)) {
|
|
3807
|
+
if (countChanged) {
|
|
3808
|
+
writer.defaultLookaheadCount =
|
|
3809
|
+
newConfig.lookaheadCount.toInt().coerceAtLeast(0)
|
|
3810
|
+
}
|
|
3811
|
+
if (cellularChanged) {
|
|
3812
|
+
writer.allowsCellularAccess = newConfig.allowsCellularAccess ?: true
|
|
3813
|
+
}
|
|
3397
3814
|
rescheduleLookahead()
|
|
3398
3815
|
}
|
|
3399
3816
|
|
|
3817
|
+
// `lookaheadCount` sizes the protected window, so a change to it must move
|
|
3818
|
+
// the window even when the branches above did not reschedule — prefetch
|
|
3819
|
+
// disabled leaves no writer, and a combined disable+count change skips the
|
|
3820
|
+
// count branch entirely. The cache still evicts in both cases.
|
|
3821
|
+
refreshCacheProtection()
|
|
3400
3822
|
emitCacheStatus()
|
|
3401
3823
|
}
|
|
3402
3824
|
|
|
3825
|
+
/**
|
|
3826
|
+
* Cancel the prefetch and wipe the cache, returning the job doing the file
|
|
3827
|
+
* work so a caller can await it. Null when there is no cache to clear.
|
|
3828
|
+
*
|
|
3829
|
+
* The wipe is a per-resource delete walk, so it runs off the app looper.
|
|
3830
|
+
* Cancelling the prefetch first is what keeps it from racing: a `CacheWriter`
|
|
3831
|
+
* mid-write into a key just removed re-creates the entry seconds later.
|
|
3832
|
+
*
|
|
3833
|
+
* Deliberately does NOT call [rescheduleLookahead] — an immediate refill makes
|
|
3834
|
+
* `clearLookaheadCache` impossible to observe at zero from JS, and "clear"
|
|
3835
|
+
* means "wipe and stop" (the consumer re-triggers prefetch via
|
|
3836
|
+
* setLookaheadCache, skipToIndex, or setQueue). Matches iOS.
|
|
3837
|
+
*/
|
|
3838
|
+
@MainThread
|
|
3403
3839
|
@VisibleForTesting
|
|
3404
|
-
internal fun clearLookaheadCacheInternal() {
|
|
3405
|
-
val cache = lookaheadCache ?: return
|
|
3406
|
-
//
|
|
3407
|
-
//
|
|
3408
|
-
//
|
|
3409
|
-
// call rescheduleLookahead() here — an immediate refill makes
|
|
3410
|
-
// `clearLookaheadCache` impossible to observe at zero from JS,
|
|
3411
|
-
// and "clear" semantically means "wipe and stop" (consumer can
|
|
3412
|
-
// re-trigger prefetch via setLookaheadCache, skipToIndex, or
|
|
3413
|
-
// setQueue). Matches iOS.
|
|
3840
|
+
internal fun clearLookaheadCacheInternal(): Job? {
|
|
3841
|
+
val cache = lookaheadCache ?: return null
|
|
3842
|
+
// Stops the blocking CacheWriter too, not just the coroutine. Its cancelled
|
|
3843
|
+
// flag is polled between chunk reads, so a span already mid-write can still
|
|
3844
|
+
// land after the wipe; this closes the window rather than eliminating it.
|
|
3414
3845
|
lookaheadCacheWriter?.cancel()
|
|
3415
|
-
|
|
3416
|
-
|
|
3846
|
+
// File work only. Emitting from in here would make the job depend on the
|
|
3847
|
+
// main thread, which deadlocks anyone awaiting it with a blocking wait on
|
|
3848
|
+
// that thread. The caller emits instead.
|
|
3849
|
+
return cacheIoScope.launch { cache.clear() }
|
|
3417
3850
|
}
|
|
3418
3851
|
|
|
3419
3852
|
/**
|
|
3420
3853
|
* Build a [CacheStatus] snapshot from the current cache + writer
|
|
3421
|
-
* state.
|
|
3422
|
-
*
|
|
3854
|
+
* state. Called off the app looper — from the `Promise.async` pool
|
|
3855
|
+
* (`getLookaheadCacheStatus`) and the serialised cache-status IO
|
|
3856
|
+
* dispatcher (`emitCacheStatus` / `onCacheStatusChange`); reads
|
|
3857
|
+
* `lookaheadCache` / `lookaheadCacheWriter` as @Volatile snapshots.
|
|
3423
3858
|
*/
|
|
3424
|
-
|
|
3859
|
+
@VisibleForTesting
|
|
3860
|
+
internal fun buildCacheStatusSnapshot(): CacheStatus {
|
|
3425
3861
|
val cache = lookaheadCache
|
|
3426
3862
|
val writer = lookaheadCacheWriter
|
|
3427
3863
|
val currentSizeBytes = cache?.currentSizeBytes() ?: 0L
|
|
@@ -3444,21 +3880,32 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3444
3880
|
)
|
|
3445
3881
|
}
|
|
3446
3882
|
|
|
3447
|
-
/**
|
|
3883
|
+
/**
|
|
3884
|
+
* Fire the cache-status listeners with a fresh snapshot. Called from main
|
|
3885
|
+
* and from the writer's IO thread (`onTrackProcessed`); the registry is
|
|
3886
|
+
* copy-on-write and the listeners run on main either way.
|
|
3887
|
+
*/
|
|
3448
3888
|
private fun emitCacheStatus() {
|
|
3449
3889
|
if (cacheStatusListeners.isEmpty) return
|
|
3450
|
-
|
|
3451
|
-
|
|
3890
|
+
// Built off the app looper. The snapshot counts fully-cached entries,
|
|
3891
|
+
// which walks every cache key and takes the cache's monitor per key —
|
|
3892
|
+
// contended against the prefetch thread that is committing downloads —
|
|
3893
|
+
// and this fires after every prefetch iteration whenever a consumer is
|
|
3894
|
+
// subscribed. Listeners are still called on the looper they expect.
|
|
3895
|
+
cacheIoScope.launch(cacheStatusDispatcher) {
|
|
3896
|
+
val snapshot = buildCacheStatusSnapshot()
|
|
3897
|
+
mainHandler.post { cacheStatusListeners.forEach { it(snapshot) } }
|
|
3898
|
+
}
|
|
3452
3899
|
}
|
|
3453
3900
|
|
|
3454
3901
|
/**
|
|
3455
|
-
*
|
|
3456
|
-
*
|
|
3457
|
-
* always dispatched on main even though the writer's coroutine
|
|
3458
|
-
* runs on Dispatchers.IO.
|
|
3902
|
+
* Emit a cache-status snapshot. Bound to the writer's `onTrackProcessed`
|
|
3903
|
+
* hook, so it fires once per prefetched track.
|
|
3459
3904
|
*/
|
|
3460
3905
|
private fun onMainPostStatusEmit() {
|
|
3461
|
-
|
|
3906
|
+
// `emitCacheStatus` does its own hopping — off the looper to build, back
|
|
3907
|
+
// onto it to deliver — so there is nothing to post first.
|
|
3908
|
+
emitCacheStatus()
|
|
3462
3909
|
}
|
|
3463
3910
|
|
|
3464
3911
|
// --- Events — observer wiring + dispatch
|
|
@@ -3489,12 +3936,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3489
3936
|
}
|
|
3490
3937
|
|
|
3491
3938
|
private fun tearDownEventObservers() {
|
|
3939
|
+
// A retry posted before teardown would otherwise land on a released engine.
|
|
3940
|
+
// Cancelled here rather than in stopProgressTick, which also runs when the
|
|
3941
|
+
// last progress subscriber unsubscribes — a screen unmounting must not kill
|
|
3942
|
+
// an in-flight retry.
|
|
3943
|
+
cancelPendingRetry()
|
|
3492
3944
|
// Clear the delegate from the engine TrackPlayer last wired
|
|
3493
3945
|
// itself onto, even if [serviceBinder] has been re-bound to a
|
|
3494
3946
|
// fresh engine since.
|
|
3495
3947
|
engineDelegateRegistration?.let { engine ->
|
|
3496
3948
|
if (engine.delegate === this) engine.delegate = null
|
|
3497
3949
|
}
|
|
3950
|
+
// And from whatever engine is bound now. A playback-mode swap carries the
|
|
3951
|
+
// delegate across to the incoming engine without this registration
|
|
3952
|
+
// following it, so clearing only the remembered one unwires an engine
|
|
3953
|
+
// that has already been released and leaves the live one still
|
|
3954
|
+
// delivering into a torn-down player.
|
|
3955
|
+
serviceBinder?.engine?.let { engine ->
|
|
3956
|
+
if (engine.delegate === this) engine.delegate = null
|
|
3957
|
+
}
|
|
3498
3958
|
engineDelegateRegistration = null
|
|
3499
3959
|
stopProgressTick()
|
|
3500
3960
|
}
|
|
@@ -3568,8 +4028,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3568
4028
|
p.replaceMediaItem(idx, updated)
|
|
3569
4029
|
}
|
|
3570
4030
|
|
|
3571
|
-
override fun onError(
|
|
3572
|
-
|
|
4031
|
+
override fun onError(
|
|
4032
|
+
engine: PlaybackEngine, exception: PlaybackException, failedIndex: Int
|
|
4033
|
+
) {
|
|
4034
|
+
handlePlayerError(exception, failedIndex)
|
|
3573
4035
|
}
|
|
3574
4036
|
|
|
3575
4037
|
override fun onPlaybackEnded(engine: PlaybackEngine) {
|
|
@@ -3594,13 +4056,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3594
4056
|
if (incomingIndex !in tracks.indices) return
|
|
3595
4057
|
currentTrackIndex = incomingIndex
|
|
3596
4058
|
lastErrorKey = null
|
|
4059
|
+
reportedFailureIndices.retainAll(setOf(incomingIndex))
|
|
3597
4060
|
val track = tracks[incomingIndex]
|
|
3598
|
-
|
|
3599
|
-
currentTrackSource = classifyTrackSource(track.url) { url ->
|
|
3600
|
-
cache?.isFullyCached(url) == true
|
|
3601
|
-
}
|
|
4061
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3602
4062
|
crossfadeFadeStartEmittedIdx = incomingIndex
|
|
3603
4063
|
pendingTrackChangeReason = null
|
|
4064
|
+
logPlaybackSource(track.url)
|
|
3604
4065
|
// New active track => new milestone playthrough (the matching
|
|
3605
4066
|
// structural transition echo is deduped in handleMediaItemTransition).
|
|
3606
4067
|
milestoneTracker.reset()
|
|
@@ -3612,6 +4073,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3612
4073
|
rescheduleLookahead()
|
|
3613
4074
|
}
|
|
3614
4075
|
|
|
4076
|
+
/**
|
|
4077
|
+
* Records what the player is actually reading for the active track.
|
|
4078
|
+
*
|
|
4079
|
+
* The reported source says whether the file is on disk; it does not say the
|
|
4080
|
+
* bytes reaching the decoder came from there. Media3 reads through
|
|
4081
|
+
* [LookaheadCache]'s CacheDataSource, so a rising cached-bytes total is the
|
|
4082
|
+
* proof that playback is being served from the cache rather than the origin.
|
|
4083
|
+
*/
|
|
4084
|
+
private fun logPlaybackSource(url: String?) {
|
|
4085
|
+
// Debug builds only, and never the query string: stream URLs carry auth
|
|
4086
|
+
// tokens there, and logcat is readable by every app on older devices.
|
|
4087
|
+
if (!BuildConfig.DEBUG) return
|
|
4088
|
+
val cachedBytes = lookaheadCache?.cachedBytesReadTotal?.get() ?: 0L
|
|
4089
|
+
Log.i(
|
|
4090
|
+
DIAG_TAG,
|
|
4091
|
+
"now-playing source=$currentTrackSource cachedBytesRead=$cachedBytes " +
|
|
4092
|
+
"url=${url?.let(PlaybackErrorMapping::stripQuery)}"
|
|
4093
|
+
)
|
|
4094
|
+
}
|
|
4095
|
+
|
|
3615
4096
|
override fun onCrossfadeCancel(engine: PlaybackEngine, revertedIndex: Int) {
|
|
3616
4097
|
// Fade-cancelled mid-flight (pause / stop / focus loss / queue
|
|
3617
4098
|
// mutation). JS already received `onTrackChange(incoming,
|
|
@@ -3623,12 +4104,14 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3623
4104
|
if (revertedIndex !in tracks.indices) return
|
|
3624
4105
|
currentTrackIndex = revertedIndex
|
|
3625
4106
|
lastErrorKey = null
|
|
4107
|
+
// The reported set is left alone. A cancel goes back to the track that was
|
|
4108
|
+
// already playing rather than announcing a new one, and the track the
|
|
4109
|
+
// standby leg was prerolling is still in the queue and still broken — the
|
|
4110
|
+
// engine seats it next. Dropping its report here would hand the listener
|
|
4111
|
+
// the same failure a second time when it does.
|
|
3626
4112
|
crossfadeFadeStartEmittedIdx = null
|
|
3627
4113
|
val track = tracks[revertedIndex]
|
|
3628
|
-
|
|
3629
|
-
currentTrackSource = classifyTrackSource(track.url) { url ->
|
|
3630
|
-
cache?.isFullyCached(url) == true
|
|
3631
|
-
}
|
|
4114
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3632
4115
|
pendingTrackChangeReason = null
|
|
3633
4116
|
trackChangeListeners.forEach {
|
|
3634
4117
|
it(track, revertedIndex.toDouble(), TrackChangeReason.CROSSFADE_CANCELLED, null)
|
|
@@ -3642,7 +4125,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3642
4125
|
val now = System.nanoTime()
|
|
3643
4126
|
gaplessGapPrevEndedAtNs = now
|
|
3644
4127
|
if (BuildConfig.DEBUG) {
|
|
3645
|
-
gaplessLogLastEndedAtNs = now
|
|
3646
4128
|
Log.d(
|
|
3647
4129
|
GAPLESS_LOG_TAG,
|
|
3648
4130
|
"item-ended trackIdx=$currentTrackIndex ts_ns=$now"
|
|
@@ -3650,7 +4132,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3650
4132
|
}
|
|
3651
4133
|
}
|
|
3652
4134
|
|
|
3653
|
-
|
|
3654
4135
|
/**
|
|
3655
4136
|
* Translate Media3 playback state + playWhenReady + suppression
|
|
3656
4137
|
* reason into our [PlayerState] enum and fire [stateChangeListeners]
|
|
@@ -3728,7 +4209,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3728
4209
|
if (index == currentTrackIndex) return
|
|
3729
4210
|
currentTrackIndex = index
|
|
3730
4211
|
val track = tracks[index]
|
|
4212
|
+
// No engine call happens on this path, so nothing downstream
|
|
4213
|
+
// reclassifies the source or recomputes the skip capability the
|
|
4214
|
+
// way handleMediaItemTransition does for a local advance.
|
|
4215
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3731
4216
|
trackChangeListeners.forEach { it(track, index.toDouble(), TrackChangeReason.AUTO_ADVANCE, null) }
|
|
4217
|
+
recomputeCapabilities()
|
|
3732
4218
|
}
|
|
3733
4219
|
|
|
3734
4220
|
/**
|
|
@@ -3748,6 +4234,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3748
4234
|
fireEndOfTrackSleepTimer()
|
|
3749
4235
|
queueEndListeners.forEach { it() }
|
|
3750
4236
|
emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
|
|
4237
|
+
// Park the engine. Reaching the end of the queue is not the same as being
|
|
4238
|
+
// paused — `playWhenReady` stays set through STATE_ENDED — so a queue
|
|
4239
|
+
// installed afterwards would start playing without anyone asking. Repeat
|
|
4240
|
+
// modes wrap without ever reaching STATE_ENDED, so they are unaffected.
|
|
4241
|
+
serviceBinder?.engine?.pause()
|
|
4242
|
+
// The intent to play ends with the queue: nothing that seats a track from
|
|
4243
|
+
// here — a skip, a seek, a fresh queue, an engine swap — starts it until
|
|
4244
|
+
// play() is called again.
|
|
4245
|
+
wantsToPlay = false
|
|
4246
|
+
// The pause's inline callback stamps USER_REQUEST; ENDED is already
|
|
4247
|
+
// reported, so that emit deduplicates and the stamp would label the next
|
|
4248
|
+
// terminal emit a user action. Nobody asked for this pause.
|
|
4249
|
+
pendingStateChangeReason = null
|
|
3751
4250
|
}
|
|
3752
4251
|
|
|
3753
4252
|
/**
|
|
@@ -3764,6 +4263,16 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3764
4263
|
* (user-skip), else fallback to [TrackChangeReason.SEEK]
|
|
3765
4264
|
* - REASON_PLAYLIST_CHANGED → [TrackChangeReason.QUEUE_REPLACED]
|
|
3766
4265
|
*/
|
|
4266
|
+
/**
|
|
4267
|
+
* Whether playback of [url] is served from the disk cache: the cache
|
|
4268
|
+
* holds the whole file AND is enabled. Disabled, every read goes to the
|
|
4269
|
+
* origin whatever the disk holds, so a complete copy does not make the
|
|
4270
|
+
* track `CACHED`. An item whose data source opened before a toggle keeps
|
|
4271
|
+
* that source, so its report can differ from the flag for that one item.
|
|
4272
|
+
*/
|
|
4273
|
+
private fun servedFromCache(url: String): Boolean =
|
|
4274
|
+
lookaheadConfig.enabled && lookaheadCache?.isFullyCached(url) == true
|
|
4275
|
+
|
|
3767
4276
|
@VisibleForTesting
|
|
3768
4277
|
internal fun handleMediaItemTransition(reason: Int) {
|
|
3769
4278
|
val p = player ?: return
|
|
@@ -3781,11 +4290,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3781
4290
|
// suppressed even though they're independent failures. Mirrors
|
|
3782
4291
|
// iOS reset in `handleCurrentItemDidChange`.
|
|
3783
4292
|
lastErrorKey = null
|
|
4293
|
+
// A report against some other track says nothing about the one now
|
|
4294
|
+
// being announced, so it goes. A report against the track being
|
|
4295
|
+
// announced stands — clearing it would leave nothing to hold on.
|
|
4296
|
+
reportedFailureIndices.retainAll(setOf(currentTrackIndex))
|
|
3784
4297
|
val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
|
|
3785
|
-
|
|
3786
|
-
currentTrackSource = classifyTrackSource(track?.url) { url ->
|
|
3787
|
-
cache?.isFullyCached(url) == true
|
|
3788
|
-
}
|
|
4298
|
+
currentTrackSource = classifyTrackSource(track?.url, ::servedFromCache)
|
|
3789
4299
|
|
|
3790
4300
|
// Crossfade post-swap echo: fade-start already fired
|
|
3791
4301
|
// `onCrossfadeBegin` which dispatched the JS-facing track-
|
|
@@ -3884,20 +4394,84 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3884
4394
|
* reschedule the proactive prefetcher after a
|
|
3885
4395
|
* queue/skip mutation. Reads `tracks` + `currentTrackIndex` (both
|
|
3886
4396
|
* @Volatile) and dispatches the next N remote tracks to
|
|
3887
|
-
* [LookaheadCacheWriter].
|
|
3888
|
-
* (configure not yet
|
|
4397
|
+
* [LookaheadCacheWriter]. Always refreshes cache protection; the prefetch
|
|
4398
|
+
* dispatch is skipped when the writer hasn't been built (configure not yet
|
|
4399
|
+
* called, or prefetch disabled).
|
|
3889
4400
|
*/
|
|
3890
4401
|
@MainThread
|
|
3891
4402
|
private fun rescheduleLookahead() {
|
|
4403
|
+
// Ahead of the writer guard on purpose: the cache is built at configure()
|
|
4404
|
+
// regardless of `lookaheadConfig.enabled`, so a consumer with prefetch off
|
|
4405
|
+
// still has a live cache that evicts, and it would otherwise run with an
|
|
4406
|
+
// empty protected set.
|
|
4407
|
+
refreshCacheProtection()
|
|
3892
4408
|
val writer = lookaheadCacheWriter ?: return
|
|
3893
4409
|
writer.reschedule(tracks, currentTrackIndex)
|
|
3894
4410
|
}
|
|
3895
4411
|
|
|
4412
|
+
/**
|
|
4413
|
+
* Tell the cache which entries eviction must not take: the playing track, the
|
|
4414
|
+
* tracks being prefetched ahead of it, and one behind for an immediate
|
|
4415
|
+
* skip-back.
|
|
4416
|
+
*
|
|
4417
|
+
* Bounded deliberately — protecting the whole remaining queue would leave
|
|
4418
|
+
* nothing evictable and turn each commit into a cache wipe.
|
|
4419
|
+
*/
|
|
4420
|
+
@MainThread
|
|
4421
|
+
private fun refreshCacheProtection() {
|
|
4422
|
+
val cache = lookaheadCache ?: return
|
|
4423
|
+
// Under LRU the set is discarded, and this runs on every queue mutation.
|
|
4424
|
+
if (!cache.honoursProtection) return
|
|
4425
|
+
val cur = currentTrackIndex
|
|
4426
|
+
if (cur < 0 || cur >= tracks.size) {
|
|
4427
|
+
cache.setProtectedUrls(emptySet())
|
|
4428
|
+
return
|
|
4429
|
+
}
|
|
4430
|
+
// The count arrives from JS as a Double, so Infinity and NaN are reachable.
|
|
4431
|
+
// Both survive `toInt()` as Int.MAX_VALUE / 0, and `cur + Int.MAX_VALUE`
|
|
4432
|
+
// wraps negative, which would make the subList bounds throw on main.
|
|
4433
|
+
// Bounded by MAX_PROTECTED_AHEAD as well as by the queue: a consumer count
|
|
4434
|
+
// at or above the queue length would protect every entry, leaving the
|
|
4435
|
+
// evictor nothing to reclaim and the cache growing past its budget with no
|
|
4436
|
+
// ceiling. The window that matters is the one around the playhead.
|
|
4437
|
+
val ahead = lookaheadConfig.lookaheadCount
|
|
4438
|
+
.takeIf { it.isFinite() }
|
|
4439
|
+
?.toInt()
|
|
4440
|
+
?.coerceIn(0, minOf(tracks.size, MAX_PROTECTED_AHEAD))
|
|
4441
|
+
?: 0
|
|
4442
|
+
val lower = (cur - 1).coerceAtLeast(0)
|
|
4443
|
+
val upper = (cur + ahead).coerceAtMost(tracks.size - 1)
|
|
4444
|
+
cache.setProtectedUrls(tracks.subList(lower, upper + 1).map { it.url }.toSet())
|
|
4445
|
+
}
|
|
4446
|
+
|
|
3896
4447
|
private fun syncCurrentTrackIndexFromPlayer(p: ExoPlayer) {
|
|
3897
4448
|
val playerIndex = p.currentMediaItemIndex
|
|
3898
4449
|
currentTrackIndex = if (playerIndex in tracks.indices) playerIndex else -1
|
|
3899
4450
|
}
|
|
3900
4451
|
|
|
4452
|
+
/** Scheduled auto-retry, held so teardown can cancel it. */
|
|
4453
|
+
private var pendingRetryRunnable: Runnable? = null
|
|
4454
|
+
|
|
4455
|
+
/**
|
|
4456
|
+
* Track index whose automatic attempt is scheduled but has not run yet. A
|
|
4457
|
+
* failure naming it is an echo of the one being retried, so it is not
|
|
4458
|
+
* reported while the attempt is still owed.
|
|
4459
|
+
*/
|
|
4460
|
+
private var pendingRetryIndex: Int = -1
|
|
4461
|
+
|
|
4462
|
+
/**
|
|
4463
|
+
* Drop a scheduled automatic attempt.
|
|
4464
|
+
*
|
|
4465
|
+
* Called wherever the queue changes, because the attempt is keyed by
|
|
4466
|
+
* position: after a mutation that index names a different track, and both
|
|
4467
|
+
* the attempt itself and the echo suppression would then be aimed at it.
|
|
4468
|
+
*/
|
|
4469
|
+
private fun cancelPendingRetry() {
|
|
4470
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4471
|
+
pendingRetryRunnable = null
|
|
4472
|
+
pendingRetryIndex = -1
|
|
4473
|
+
}
|
|
4474
|
+
|
|
3901
4475
|
/**
|
|
3902
4476
|
* Translate [PlaybackException] into a [PlaybackError] + emit on
|
|
3903
4477
|
* both [errorListeners] and as a terminal ERROR state. Mirrors iOS
|
|
@@ -3905,27 +4479,67 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3905
4479
|
* conditions surface as non-fatal so consumers can offer retry
|
|
3906
4480
|
* UX; codec / decoding errors surface as fatal.
|
|
3907
4481
|
*/
|
|
3908
|
-
private fun handlePlayerError(error: PlaybackException) {
|
|
4482
|
+
private fun handlePlayerError(error: PlaybackException, failedIndex: Int = -1) {
|
|
3909
4483
|
val mapped = PlaybackErrorMapping.classify(error)
|
|
3910
|
-
//
|
|
4484
|
+
// Attribute to the track that actually failed. Under crossfade the
|
|
4485
|
+
// standby leg can fail while prerolling an upcoming track, and the
|
|
4486
|
+
// leading leg plays on — keying any of the three uses below off
|
|
4487
|
+
// currentTrackIndex would dedup against the wrong track, spend the wrong
|
|
4488
|
+
// track's retry budget, and report the wrong url to the consumer.
|
|
4489
|
+
val idx = if (failedIndex in tracks.indices) failedIndex else currentTrackIndex
|
|
4490
|
+
// Dedup by (failing index, standardized code). Mirrors iOS
|
|
3911
4491
|
// `(queueItemId, code)` dedup. ExoPlayer can fire onPlayerError
|
|
3912
4492
|
// multiple times for a single failed item under retry storms.
|
|
3913
|
-
val key =
|
|
4493
|
+
val key = idx to mapped
|
|
4494
|
+
// An echo of a failure whose attempt has not run yet belongs to that
|
|
4495
|
+
// attempt, not to the consumer.
|
|
4496
|
+
if (idx >= 0 && idx == pendingRetryIndex) return
|
|
4497
|
+
// Already reported and not yet recovered. Under crossfade the standby leg
|
|
4498
|
+
// prerolls the next track, so a broken one is reported while the leading
|
|
4499
|
+
// track still plays; the engine then seats it and it fails again as the
|
|
4500
|
+
// current track. `lastErrorKey` cannot carry that on its own — the track
|
|
4501
|
+
// change between the two clears it — so one thing asked for would report
|
|
4502
|
+
// twice. The state still has to be announced when this becomes the track
|
|
4503
|
+
// the listener is on, because the report was raised before it was.
|
|
4504
|
+
if (idx >= 0 && idx in reportedFailureIndices) {
|
|
4505
|
+
if (idx == currentTrackIndex) emitState(PlayerState.ERROR, StateChangeReason.ERROR)
|
|
4506
|
+
return
|
|
4507
|
+
}
|
|
4508
|
+
// Once the queue has stopped on the current track, the tracks behind it go
|
|
4509
|
+
// quiet: the listener has been told the track they are on failed, and one
|
|
4510
|
+
// error per unplayable track in the run is noise they cannot act on. While
|
|
4511
|
+
// playback is healthy this does not fire, so a standby leg failing to
|
|
4512
|
+
// preroll under crossfade is still surfaced. Mirrors iOS.
|
|
4513
|
+
if (idx >= 0 && idx != currentTrackIndex &&
|
|
4514
|
+
currentTrackIndex in reportedFailureIndices) {
|
|
4515
|
+
return
|
|
4516
|
+
}
|
|
3914
4517
|
if (lastErrorKey == key) return
|
|
3915
4518
|
lastErrorKey = key
|
|
3916
4519
|
// Auto-retry path: transient errors with retries left
|
|
3917
4520
|
// schedule a delayed `prepare()`. Skip the JS error emit + state
|
|
3918
4521
|
// ERROR transition until retries exhausted; consumer sees onError
|
|
3919
4522
|
// only when the lib has given up. Mirrors iOS path.
|
|
3920
|
-
|
|
4523
|
+
// Only the track being played is retried. A standby leg's preroll failing
|
|
4524
|
+
// is the *next* track failing to load early, and the cancel that precedes
|
|
4525
|
+
// the report reverts `currentTrackIndex` to the leading track, so an
|
|
4526
|
+
// attempt scheduled for it could never run. It is surfaced instead, and
|
|
4527
|
+
// gets its own automatic attempt when it becomes current and loads fresh.
|
|
3921
4528
|
if (idx >= 0 &&
|
|
4529
|
+
idx == currentTrackIndex &&
|
|
3922
4530
|
PlaybackErrorMapping.isTransient(mapped) &&
|
|
3923
4531
|
(retryAttemptsRemaining[idx] ?: 0) > 0) {
|
|
3924
4532
|
retryAttemptsRemaining[idx] = retryAttemptsRemaining[idx]!! - 1
|
|
3925
4533
|
// Clear dedup so the retry-then-fail (if any) fires fresh.
|
|
3926
4534
|
lastErrorKey = null
|
|
3927
4535
|
val backoff = effectiveRetryBackoffMs()
|
|
3928
|
-
|
|
4536
|
+
// Hold the runnable so teardown can drop it. A retry posted moments
|
|
4537
|
+
// before destroy() would otherwise fire against a released engine.
|
|
4538
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4539
|
+
val retry = Runnable { retryFailedItem(idx) }
|
|
4540
|
+
pendingRetryRunnable = retry
|
|
4541
|
+
pendingRetryIndex = idx
|
|
4542
|
+
mainHandler.postDelayed(retry, backoff.toLong())
|
|
3929
4543
|
return
|
|
3930
4544
|
}
|
|
3931
4545
|
// Surface the failing track's URL so consumers can correlate the
|
|
@@ -3938,17 +4552,28 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3938
4552
|
val err = PlaybackErrorMapping.buildPlaybackError(
|
|
3939
4553
|
error, mapped, queueItemId = "", url = failedUrl,
|
|
3940
4554
|
)
|
|
4555
|
+
// The consumer now knows this track failed, so the queue holds on it until
|
|
4556
|
+
// something asks for playback again. Recorded for whichever track failed,
|
|
4557
|
+
// current or not: that is what keeps the standby leg's report from being
|
|
4558
|
+
// handed to the listener a second time when the engine seats it.
|
|
4559
|
+
if (idx >= 0) reportedFailureIndices.add(idx)
|
|
3941
4560
|
errorListeners.forEach { it(err) }
|
|
3942
|
-
|
|
4561
|
+
// The state is the current track's. A standby leg's preroll failing is
|
|
4562
|
+
// reported above and leaves the leading leg playing; announcing ERROR for
|
|
4563
|
+
// it would put a terminal state under audio that is still running, and
|
|
4564
|
+
// the next engine callback would flip it straight back to PLAYING.
|
|
4565
|
+
if (idx == currentTrackIndex) {
|
|
4566
|
+
emitState(PlayerState.ERROR, StateChangeReason.ERROR)
|
|
4567
|
+
}
|
|
3943
4568
|
}
|
|
3944
4569
|
|
|
3945
4570
|
// --- Auto-retry + stuck-recovery
|
|
3946
4571
|
|
|
3947
4572
|
/** Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
|
|
3948
|
-
* defaults to
|
|
4573
|
+
* defaults to 1, clamps to [0, 5]. */
|
|
3949
4574
|
@VisibleForTesting
|
|
3950
4575
|
internal fun effectiveAutoRetries(): Int =
|
|
3951
|
-
(config.autoRetries?.toInt() ?:
|
|
4576
|
+
(config.autoRetries?.toInt() ?: 1).coerceIn(0, 5)
|
|
3952
4577
|
|
|
3953
4578
|
/** Lib-clamped retry backoff in milliseconds. Defaults to 500,
|
|
3954
4579
|
* clamps to [200, 5000]. Floor at 200ms prevents retry storms
|
|
@@ -3959,31 +4584,56 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3959
4584
|
|
|
3960
4585
|
/** Try to recover from a transient failure by re-preparing the
|
|
3961
4586
|
* ExoPlayer at the current position. Called from
|
|
3962
|
-
* [handlePlayerError] (auto-retry)
|
|
3963
|
-
*
|
|
3964
|
-
*
|
|
4587
|
+
* [handlePlayerError] (auto-retry) and from [retry] (the
|
|
4588
|
+
* consumer-facing one-shot). Mirrors iOS `retryFailedItem`, which
|
|
4589
|
+
* rebuilds the AVQueuePlayer via `fullRebuildPlayerQueue` and seeks
|
|
4590
|
+
* back to the captured position.
|
|
3965
4591
|
*
|
|
3966
|
-
* Implementation:
|
|
3967
|
-
*
|
|
3968
|
-
* the existing wedged DataSource
|
|
3969
|
-
* failure and may not actually recover.
|
|
3970
|
-
*
|
|
3971
|
-
* the MediaSource.Factory (carries the current `httpFactory`
|
|
3972
|
-
* + cache wrapper).
|
|
3973
|
-
* current playback position; iOS rebuild resets to 0 (mid-play
|
|
3974
|
-
* failure loses position regardless). For initial-load failures
|
|
3975
|
-
* the position is 0 anyway, so the position-preservation here
|
|
3976
|
-
* is the only intentional iOS/Android divergence. */
|
|
4592
|
+
* Implementation: remove + re-add the failed item at its index,
|
|
4593
|
+
* `seekTo` the captured position, then `prepare()` — rather than
|
|
4594
|
+
* bare `prepare()`, which re-uses the existing wedged DataSource
|
|
4595
|
+
* on a "Cannot Open"-class failure and may not actually recover.
|
|
4596
|
+
* Re-adding the item forces Media3 to construct a fresh DataSource
|
|
4597
|
+
* from the MediaSource.Factory (carries the current `httpFactory`
|
|
4598
|
+
* + cache wrapper). */
|
|
3977
4599
|
internal fun retryFailedItem(atIndex: Int) {
|
|
3978
|
-
|
|
3979
|
-
//
|
|
3980
|
-
//
|
|
3981
|
-
// the
|
|
3982
|
-
|
|
4600
|
+
// The attempt has arrived, so whatever fails next is a new failure rather
|
|
4601
|
+
// than an echo of the one being retried. A scheduled attempt for the same
|
|
4602
|
+
// index is withdrawn with it: left posted, it would fire after the backoff
|
|
4603
|
+
// and rebuild the track a second time behind a user's own retry.
|
|
4604
|
+
if (pendingRetryIndex == atIndex) {
|
|
4605
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4606
|
+
pendingRetryIndex = -1
|
|
4607
|
+
pendingRetryRunnable = null
|
|
4608
|
+
}
|
|
4609
|
+
// The attempt re-seats the item, so the queue is no longer stopped on it.
|
|
4610
|
+
reportedFailureIndices.remove(atIndex)
|
|
4611
|
+
// The failed item must still be the current one; a skip or a queue mutation
|
|
4612
|
+
// since the failure makes the retry moot. Checked before the fade is
|
|
4613
|
+
// settled, because settling is an audible cut and a retry that is about to
|
|
4614
|
+
// return has no business making one.
|
|
3983
4615
|
if (atIndex != currentTrackIndex) return
|
|
4616
|
+
// Settle a fade before reading the player. `player` is the leading leg,
|
|
4617
|
+
// which mid-fade holds the OUTGOING track while `currentTrackIndex` has
|
|
4618
|
+
// already flipped to the incoming one — so every read below would describe
|
|
4619
|
+
// a different track than the index check just approved, and a retry would
|
|
4620
|
+
// rebuild and restart the track the user is leaving. Promoting first makes
|
|
4621
|
+
// the leading leg the track being heard, which is the one a retry is for.
|
|
4622
|
+
serviceBinder?.engine?.settlePendingFadeByPromoting()
|
|
4623
|
+
val p = player ?: return
|
|
3984
4624
|
val current = p.currentMediaItem
|
|
3985
4625
|
if (current != null) {
|
|
3986
|
-
|
|
4626
|
+
// Act on the index that was approved above, not on whatever the engine
|
|
4627
|
+
// happens to be pointing at. They agree in every path found so far — the
|
|
4628
|
+
// standby leg carries the whole queue so its index is in the queue's own
|
|
4629
|
+
// space, and the settle above puts the leading leg on the current track —
|
|
4630
|
+
// but nothing enforces it, and a disagreement means the engine has not
|
|
4631
|
+
// caught up with a mutation. Rebuilding at either index would then be
|
|
4632
|
+
// wrong: the track being heard gets torn down and restarted at another
|
|
4633
|
+
// track's position, because `resumeMs` below is that track's. Refuse
|
|
4634
|
+
// instead; whatever asks next attempts it again.
|
|
4635
|
+
if (atIndex >= p.mediaItemCount || p.currentMediaItemIndex != atIndex) return
|
|
4636
|
+
val idx = atIndex
|
|
3987
4637
|
val resumeMs = p.currentPosition.coerceAtLeast(0L)
|
|
3988
4638
|
// Force a FRESH MediaSource/DataSource for the failed item without
|
|
3989
4639
|
// collapsing the rest of the timeline. `replaceMediaItem(idx, current)`
|
|
@@ -4002,10 +4652,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4002
4652
|
} else {
|
|
4003
4653
|
p.prepare()
|
|
4004
4654
|
}
|
|
4005
|
-
// Resume
|
|
4006
|
-
// (
|
|
4007
|
-
//
|
|
4008
|
-
if (!p.isPlaying) serviceBinder?.engine?.play()
|
|
4655
|
+
// Resume only if playback is wanted NOW, not at failure time: a manual
|
|
4656
|
+
// retry() carries no failure-time flag, and a pause during the retry
|
|
4657
|
+
// backoff must win over the state the failure was scheduled under.
|
|
4658
|
+
if (wantsToPlay && !p.isPlaying) serviceBinder?.engine?.play()
|
|
4009
4659
|
}
|
|
4010
4660
|
|
|
4011
4661
|
/**
|
|
@@ -4268,13 +4918,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4268
4918
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
4269
4919
|
body()
|
|
4270
4920
|
} else {
|
|
4271
|
-
val
|
|
4921
|
+
val done = CountDownLatch(1)
|
|
4272
4922
|
var err: Throwable? = null
|
|
4273
4923
|
mainHandler.post {
|
|
4274
4924
|
try { body() } catch (t: Throwable) { err = t }
|
|
4275
|
-
|
|
4925
|
+
done.countDown()
|
|
4276
4926
|
}
|
|
4277
|
-
|
|
4927
|
+
done.await()
|
|
4278
4928
|
err?.let { throw it }
|
|
4279
4929
|
}
|
|
4280
4930
|
}
|
|
@@ -4299,18 +4949,45 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4299
4949
|
}
|
|
4300
4950
|
|
|
4301
4951
|
companion object {
|
|
4952
|
+
/** Prefs holding the eviction policy the on-disk cache was built with. */
|
|
4953
|
+
private const val CACHE_PREFS_NAME = "rnqp_lookahead_cache"
|
|
4954
|
+
private const val KEY_EVICTION_POLICY = "eviction_policy"
|
|
4955
|
+
|
|
4956
|
+
/** Marks a cache directory renamed aside and awaiting deletion. */
|
|
4957
|
+
private const val DELETING_DIR_SUFFIX = ".deleting-"
|
|
4958
|
+
|
|
4959
|
+
/**
|
|
4960
|
+
* Renamed cache directories this process is currently deleting.
|
|
4961
|
+
*
|
|
4962
|
+
* The sweep collects anything wearing the deleting- prefix, which is
|
|
4963
|
+
* exactly the name a discard just produced — so without this it starts a
|
|
4964
|
+
* second delete of a tree already being deleted, with a second database
|
|
4965
|
+
* provider dropping the same index tables underneath the first.
|
|
4966
|
+
*
|
|
4967
|
+
* Process-wide because the cache is: a second [TrackPlayer] configured in
|
|
4968
|
+
* the same process sweeps the same directory. Held only while a delete is
|
|
4969
|
+
* in flight, so a directory abandoned by a previous process — the case the
|
|
4970
|
+
* sweep exists for — is never claimed and is always collected.
|
|
4971
|
+
*/
|
|
4972
|
+
private val doomedCacheDirectories = java.util.Collections.synchronizedSet(
|
|
4973
|
+
mutableSetOf<String>())
|
|
4974
|
+
|
|
4975
|
+
private fun claimDoomedDirectory(dir: java.io.File) {
|
|
4976
|
+
doomedCacheDirectories.add(dir.absolutePath)
|
|
4977
|
+
}
|
|
4978
|
+
|
|
4979
|
+
private fun releaseDoomedDirectory(dir: java.io.File) {
|
|
4980
|
+
doomedCacheDirectories.remove(dir.absolutePath)
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4983
|
+
private fun isDoomedDirectoryClaimed(dir: java.io.File): Boolean =
|
|
4984
|
+
doomedCacheDirectories.contains(dir.absolutePath)
|
|
4985
|
+
|
|
4302
4986
|
/** Progress tick cadence — matches iOS 2 Hz. */
|
|
4303
4987
|
private const val PROGRESS_TICK_INTERVAL_MS = 500L
|
|
4304
4988
|
/** Sleep-timer countdown/fade cadence — 500ms gives a smooth 10s fade. */
|
|
4305
4989
|
private const val SLEEP_TIMER_TICK_INTERVAL_MS = 500L
|
|
4306
4990
|
|
|
4307
|
-
/**
|
|
4308
|
-
* Disposer returned from `on*Request` registrations when the
|
|
4309
|
-
* `PlaybackService` isn't bound yet. Calling it is a no-op —
|
|
4310
|
-
* the registration never landed, so there's nothing to clear.
|
|
4311
|
-
*/
|
|
4312
|
-
private val NO_OP_DISPOSER: () -> Unit = {}
|
|
4313
|
-
|
|
4314
4991
|
/** Tolerance for "bufferedPosition has reached duration" — a CBR-estimated
|
|
4315
4992
|
* duration or rounding can leave a sub-second gap when fully downloaded. */
|
|
4316
4993
|
private const val FULLY_BUFFERED_EPSILON_MS = 1000L
|
|
@@ -4320,6 +4997,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4320
4997
|
* reference one named constant. */
|
|
4321
4998
|
private const val MS_PER_SECOND = 1000.0
|
|
4322
4999
|
|
|
5000
|
+
/**
|
|
5001
|
+
* Upper bound on how far ahead of the playhead cache eviction is blocked.
|
|
5002
|
+
*
|
|
5003
|
+
* Independent of the consumer's lookahead count on purpose: protecting the
|
|
5004
|
+
* whole queue leaves the evictor nothing to reclaim, and the cache then
|
|
5005
|
+
* grows past its budget with no ceiling.
|
|
5006
|
+
*/
|
|
5007
|
+
private const val MAX_PROTECTED_AHEAD = 10
|
|
5008
|
+
|
|
4323
5009
|
/**
|
|
4324
5010
|
* Tag for the verbose gapless-transition logs (item-ended /
|
|
4325
5011
|
* item-changed / playing). All emit sites are guarded by
|
|
@@ -4328,6 +5014,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4328
5014
|
*/
|
|
4329
5015
|
private const val GAPLESS_LOG_TAG = "RNQP-GAPLESS"
|
|
4330
5016
|
|
|
5017
|
+
/** Playback-source diagnostics: what the player actually reads from. */
|
|
5018
|
+
private const val DIAG_TAG = "RNQP-DIAG"
|
|
5019
|
+
|
|
4331
5020
|
/** Logcat tag for player-level lifecycle warnings. Part of the
|
|
4332
5021
|
* `QueuePlayer.<area>` hierarchy used across the lib's Android
|
|
4333
5022
|
* sources. */
|
|
@@ -4347,6 +5036,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4347
5036
|
var current: TrackPlayer? = null
|
|
4348
5037
|
private set
|
|
4349
5038
|
|
|
5039
|
+
init {
|
|
5040
|
+
// One process-wide listener on the cast route, forwarding to whichever
|
|
5041
|
+
// instance is configured. Push-PCM sessions (AirPlay 1/2) force-degrade
|
|
5042
|
+
// the active engine from Crossfade to Gapless because the wire protocol
|
|
5043
|
+
// cannot mix two ExoPlayers into one sink; clearing the session
|
|
5044
|
+
// re-applies the user's requested mode (so crossfade re-instates).
|
|
5045
|
+
// Registered once here rather than per instance: `CastSinkRouter` is a
|
|
5046
|
+
// process-wide object, and a per-instance listener kept every instance
|
|
5047
|
+
// ever constructed reachable and ran the swap on all of them.
|
|
5048
|
+
com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
|
|
5049
|
+
val player = current ?: return@addRouteChangeListener
|
|
5050
|
+
// Re-apply against the user's requested mode on every flip.
|
|
5051
|
+
// `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
|
|
5052
|
+
// for the degrade decision, so the swap settles to the right
|
|
5053
|
+
// engine for the new route state.
|
|
5054
|
+
val mode = player.playbackModeState.active
|
|
5055
|
+
player.mainHandler.post { player.applyEngineSwapForMode(mode) }
|
|
5056
|
+
}
|
|
5057
|
+
}
|
|
5058
|
+
|
|
4350
5059
|
/**
|
|
4351
5060
|
* Classify the active-track playback source. `null` when the URL
|
|
4352
5061
|
* is null or its scheme is neither `file://` nor `http(s)://`
|