react-native-queue-player 1.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QueuePlayer.podspec +14 -9
- package/README.md +3 -3
- package/android/build.gradle +7 -0
- package/android/consumer-rules.pro +72 -3
- package/android/src/main/cpp/airplay2_control.c +4 -4
- package/android/src/main/cpp/airplay2_control.h +4 -4
- package/android/src/main/cpp/airplay2_jni.cpp +26 -22
- package/android/src/main/cpp/airplay2_pair.c +7 -7
- package/android/src/main/cpp/airplay2_pair.h +2 -2
- package/android/src/main/cpp/airplay2_rtsp.c +8 -8
- package/android/src/main/cpp/airplay2_session.c +2 -2
- package/android/src/main/cpp/airplay_jni.cpp +20 -72
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
- package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
- package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
- package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
- package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +870 -278
- package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +23 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
- package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +23 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +8 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
- package/app.plugin.js +17 -1
- package/ios/AVPlayerItemQueueItemId.swift +26 -12
- package/ios/AVQueueBuilder.swift +120 -85
- package/ios/ArtworkLoader.swift +0 -7
- package/ios/ArtworkResolver.swift +22 -4
- package/ios/AssetReplayGainReader.swift +118 -0
- package/ios/AudioSession.swift +67 -16
- package/ios/AudioTapProvider.swift +284 -149
- package/ios/CarPlayBridge.swift +0 -12
- package/ios/CarPlayCoordinator.swift +18 -2
- package/ios/CarPlaySceneDelegate.swift +2 -2
- package/ios/Cast/AirPlayRouteState.swift +9 -8
- package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
- package/ios/Cast/Core/CastEventBridge.swift +66 -26
- package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
- package/ios/Cast/Core/CastSession.swift +11 -0
- package/ios/Cast/Core/CastTransportRouter.swift +21 -9
- package/ios/Cast/Core/LocalMediaServer.swift +41 -52
- package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
- package/ios/Cast/Core/MediaServerHandle.swift +0 -4
- package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
- package/ios/CastManager.swift +1 -1
- package/ios/CrossfadeEngine.swift +660 -226
- package/ios/EQTap.swift +20 -17
- package/ios/Equalizer.swift +1 -1
- package/ios/FLACStreamInfo.swift +65 -0
- package/ios/GaplessEngine.swift +57 -30
- package/ios/InputGuards.swift +35 -5
- package/ios/LookaheadCache.swift +202 -58
- package/ios/LookaheadCachePrefetcher.swift +48 -36
- package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
- package/ios/NetworkRecoveryPolicy.swift +32 -0
- package/ios/NowPlayingFormatExtractor.swift +25 -18
- package/ios/NowPlayingInfo.swift +84 -48
- package/ios/OutputRouteMonitor.swift +16 -3
- package/ios/PendingBrowseRequests.swift +1 -1
- package/ios/PlaceholderArtwork.swift +9 -5
- package/ios/PlaybackEngine.swift +165 -76
- package/ios/PlaybackErrorMapping.swift +3 -3
- package/ios/PlaybackModeStateMachine.swift +2 -2
- package/ios/PlaybackNetworkMonitor.swift +73 -0
- package/ios/PlayerStateDerivation.swift +6 -6
- package/ios/QueueMutationArithmetic.swift +25 -0
- package/ios/QueueSkipArithmetic.swift +5 -3
- package/ios/QueueWindowArithmetic.swift +152 -0
- package/ios/ReadThroughServer.swift +283 -0
- package/ios/RemoteCommands.swift +44 -4
- package/ios/ReplayGainData.swift +22 -3
- package/ios/ReplayGainExtractor.swift +70 -27
- package/ios/Siri/VoiceDonation.swift +14 -9
- package/ios/StreamingBitrateProbe.swift +22 -68
- package/ios/SuppliedReplayGain.swift +83 -0
- package/ios/Tests/AVQueueBuilderTests.swift +158 -221
- package/ios/Tests/ActiveItemEngineStub.swift +44 -0
- package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
- package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
- package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
- package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
- package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
- package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
- package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
- package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
- package/ios/Tests/CrossfadeEngineTests.swift +164 -0
- package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
- package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
- package/ios/Tests/EngineSwapTests.swift +91 -0
- package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
- package/ios/Tests/EqualizerHybridTests.swift +5 -2
- package/ios/Tests/FLACStreamInfoTests.swift +88 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
- package/ios/Tests/InputGuardsTests.swift +41 -0
- package/ios/Tests/InterruptionIntentTests.swift +77 -0
- package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
- package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
- package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
- package/ios/Tests/LookaheadCacheTests.swift +44 -18
- package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
- package/ios/Tests/MutationDeferralTests.swift +220 -0
- package/ios/Tests/NowPlayingInfoTests.swift +86 -99
- package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
- package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
- package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
- package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
- package/ios/Tests/PlayerFixtures.swift +48 -0
- package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
- package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
- package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
- package/ios/Tests/QueueStateTests.swift +3 -1
- package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
- package/ios/Tests/QueueWindowSliceTests.swift +162 -0
- package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
- package/ios/Tests/ReadThroughServerTests.swift +345 -0
- package/ios/Tests/RemoteCommandsTests.swift +28 -0
- package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
- package/ios/Tests/ReplayGainMergeTests.swift +230 -0
- package/ios/Tests/RetryRecoveryTests.swift +303 -0
- package/ios/Tests/SkipCapabilityTests.swift +233 -8
- package/ios/Tests/SkipIndexTests.swift +32 -2
- package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
- package/ios/Tests/StallRecoveryTests.swift +97 -0
- package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
- package/ios/Tests/TopUpWindowGateTests.swift +394 -0
- package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
- package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
- package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
- package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
- package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
- package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
- package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
- package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
- package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
- package/ios/TrackPlayer+Automotive.swift +101 -0
- package/ios/TrackPlayer+Cache.swift +232 -0
- package/ios/TrackPlayer+Config.swift +291 -0
- package/ios/TrackPlayer+EngineDelegate.swift +209 -0
- package/ios/TrackPlayer+EventsAPI.swift +111 -0
- package/ios/TrackPlayer+EventsDispatch.swift +968 -0
- package/ios/TrackPlayer+EventsWiring.swift +173 -0
- package/ios/TrackPlayer+Lifecycle.swift +930 -0
- package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
- package/ios/TrackPlayer+Queue.swift +940 -0
- package/ios/TrackPlayer+Recovery.swift +196 -0
- package/ios/TrackPlayer+Skip.swift +455 -0
- package/ios/TrackPlayer+SleepTimer.swift +175 -0
- package/ios/TrackPlayer+State.swift +108 -0
- package/ios/TrackPlayer+Threading.swift +135 -0
- package/ios/TrackPlayer+Transport.swift +267 -0
- package/ios/TrackPlayer+Window.swift +195 -0
- package/ios/TrackPlayer.swift +305 -4610
- package/ios/Visualizer.swift +6 -5
- package/ios/tests-harness/Podfile +1 -1
- package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
- package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
- package/lib/module/hooks/useActiveTrack.js +29 -22
- package/lib/module/hooks/useActiveTrack.js.map +1 -1
- package/lib/module/hooks/useCast.js +8 -26
- package/lib/module/hooks/useCast.js.map +1 -1
- package/lib/module/hooks/useEqualizer.js +19 -12
- package/lib/module/hooks/useEqualizer.js.map +1 -1
- package/lib/module/hooks/useLookaheadCache.js +3 -7
- package/lib/module/hooks/useLookaheadCache.js.map +1 -1
- package/lib/module/hooks/useQueue.js +66 -19
- package/lib/module/hooks/useQueue.js.map +1 -1
- package/lib/module/index.js +6 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/queueDelta.js +41 -0
- package/lib/module/queueDelta.js.map +1 -0
- package/lib/module/types.js +28 -5
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
- package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
- package/lib/typescript/hooks/useCast.d.ts +6 -1
- package/lib/typescript/hooks/useCast.d.ts.map +1 -1
- package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
- package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
- package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +4 -4
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/queueDelta.d.ts +10 -0
- package/lib/typescript/queueDelta.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +168 -12
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
- package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
- package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
- package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
- package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
- package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
- package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
- package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
- package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
- package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
- package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
- package/package.json +8 -8
- package/src/TrackPlayer.nitro.ts +53 -27
- package/src/hooks/useActiveTrack.ts +29 -22
- package/src/hooks/useCast.ts +14 -15
- package/src/hooks/useEqualizer.ts +19 -12
- package/src/hooks/useLookaheadCache.ts +3 -7
- package/src/hooks/useQueue.ts +66 -17
- package/src/index.ts +13 -3
- package/src/queueDelta.ts +41 -0
- package/src/types.ts +169 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
- package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
- package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
- package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
- package/ios/MetadataReader.swift +0 -555
- package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
- package/ios/Tests/MetadataReaderTests.swift +0 -603
- package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
- package/ios/Tests/VoiceDonationTests.swift +0 -26
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
- /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
|
@@ -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().
|
|
@@ -700,9 +846,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
700
846
|
* transient-error fire; when 0, the error surfaces to JS.
|
|
701
847
|
* Re-populated on `setQueueInternal` /
|
|
702
848
|
* `addToQueueInternal` from `effectiveAutoRetries()` (clamped
|
|
703
|
-
* 0..5; default
|
|
704
|
-
*
|
|
705
|
-
*
|
|
849
|
+
* 0..5; default 1). Restored to the configured value by `retry()`, so
|
|
850
|
+
* a manual attempt gives whatever fails next its configured automatic
|
|
851
|
+
* allowance. `play()` restores the same configured value when the counter
|
|
852
|
+
* is 0 — stuck-recovery on a user re-tap. Cleared on `removeFromQueue` / `moveInQueue`
|
|
706
853
|
* (index shifts invalidate keys; budget resets are an acceptable
|
|
707
854
|
* trade-off vs walking + remapping the map per shifted index). */
|
|
708
855
|
private var retryAttemptsRemaining: MutableMap<Int, Int> = mutableMapOf()
|
|
@@ -874,25 +1021,34 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
874
1021
|
internal fun configureInternal(newConfig: PlayerConfig, context: Context) {
|
|
875
1022
|
// Eviction policy is fixed at SimpleCache construction; a change clears the
|
|
876
1023
|
// cache + rebuilds under the new evictor (infrequent — see FifoCacheEvictor).
|
|
877
|
-
//
|
|
878
|
-
// destroyInternal
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
1024
|
+
// The service still holds the engine `configurePlayer` replaces further
|
|
1025
|
+
// down — `destroyInternal` drops this player's references, not the
|
|
1026
|
+
// service's engine — and that engine reads through the shared cache, so it
|
|
1027
|
+
// is stopped before the cache is released and its directory renamed away.
|
|
1028
|
+
// Compare against what was last APPLIED, not against the in-memory config.
|
|
1029
|
+
// A fresh process starts at the LRU default, so a FIFO consumer would
|
|
1030
|
+
// otherwise read every cold launch as a policy change and wipe its cache
|
|
1031
|
+
// before it could ever be used.
|
|
1032
|
+
val previousEvictionPolicy =
|
|
1033
|
+
persistedEvictionPolicy(context) ?: evictionPolicyFrom(this.config)
|
|
882
1034
|
this.config = newConfig
|
|
883
1035
|
// Swap the cover-art placeholder bitmap to the consumer-supplied image
|
|
884
1036
|
// (or back to the built-in when unset). The no-art metadata URI is
|
|
885
1037
|
// threaded separately at each buildMediaItems call.
|
|
886
1038
|
PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
|
|
887
1039
|
if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
|
|
1040
|
+
serviceBinder?.engine?.stop()
|
|
888
1041
|
lookaheadCacheWriter?.release()
|
|
889
1042
|
lookaheadCacheWriter = null
|
|
890
1043
|
lookaheadCache?.release()
|
|
891
1044
|
lookaheadCache = null
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1045
|
+
cellularTransportMonitor?.stop()
|
|
1046
|
+
cellularTransportMonitor = null
|
|
1047
|
+
discardCacheDirectory(context)
|
|
895
1048
|
}
|
|
1049
|
+
// Collect what a previous process left mid-delete. Directories this
|
|
1050
|
+
// process is still deleting are claimed and skipped.
|
|
1051
|
+
sweepAbandonedCacheDirectories(context)
|
|
896
1052
|
// Seed the progress-emission throttle from config and start observing app
|
|
897
1053
|
// background/foreground. Both are idempotent, so a re-configure refreshes
|
|
898
1054
|
// the rate without double-registering the lifecycle observer.
|
|
@@ -907,6 +1063,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
907
1063
|
// new field.
|
|
908
1064
|
VisualizationGate.setEnabled(newConfig.visualizationEnabled ?: true)
|
|
909
1065
|
buildLookaheadStack(newConfig, context)
|
|
1066
|
+
// Recorded only once a cache actually exists under this policy, so a failed
|
|
1067
|
+
// build cannot leave a policy stamped that nothing was built with.
|
|
1068
|
+
if (lookaheadCache != null) {
|
|
1069
|
+
persistEvictionPolicy(context, evictionPolicyFrom(newConfig))
|
|
1070
|
+
}
|
|
1071
|
+
// configure() destroys first, so the queue is empty here and this clears
|
|
1072
|
+
// protection rather than seeding it. That is the point: the evictor lives
|
|
1073
|
+
// in a process-wide shared instance that outlives a destroy/configure
|
|
1074
|
+
// cycle, so without this it would keep shielding the previous session's
|
|
1075
|
+
// tracks until the next queue mutation.
|
|
1076
|
+
refreshCacheProtection()
|
|
910
1077
|
val mediaSourceFactory =
|
|
911
1078
|
MediaItemBuilder.buildMediaSourceFactory(context, newConfig, lookaheadCache)
|
|
912
1079
|
val binder = serviceBinder
|
|
@@ -943,6 +1110,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
943
1110
|
engine.setVolume(volumeState)
|
|
944
1111
|
engine.setPlaybackSpeed(playbackSpeedState)
|
|
945
1112
|
engine.setPitchCorrectionMode(pitchCorrectionModeState)
|
|
1113
|
+
installSuppliedReplayGainResolver(engine)
|
|
946
1114
|
}
|
|
947
1115
|
installEventObservers()
|
|
948
1116
|
binder.setCommandHandler(mediaSessionCommandHandler)
|
|
@@ -950,7 +1118,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
950
1118
|
}
|
|
951
1119
|
|
|
952
1120
|
/**
|
|
953
|
-
* Bridge from
|
|
1121
|
+
* Bridge from `PlaybackService` — its [SessionCommandForwardingPlayer],
|
|
1122
|
+
* [MediaButtonDispatch] and audio-focus listener — back into this
|
|
954
1123
|
* TrackPlayer. Stamps the `system` pending-reason for transports
|
|
955
1124
|
* that the system surface drives (lock-screen, Bluetooth, Android
|
|
956
1125
|
* Auto, hardware media keys), and routes skip-next / skip-previous
|
|
@@ -974,12 +1143,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
974
1143
|
}
|
|
975
1144
|
|
|
976
1145
|
override fun onSkipToNextCommand(): Boolean {
|
|
1146
|
+
// Cast routing before the local body, same early-return contract as
|
|
1147
|
+
// the public transport methods. The `true` return suppresses the
|
|
1148
|
+
// forwarding player's default seek either way, so the receiver gets
|
|
1149
|
+
// exactly one skip. The reason stamp sits on the local path because
|
|
1150
|
+
// that is the only path whose listener consumes it.
|
|
1151
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return true
|
|
977
1152
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
978
1153
|
skipToNextInternal()
|
|
979
1154
|
return true
|
|
980
1155
|
}
|
|
981
1156
|
|
|
982
1157
|
override fun onSkipToPreviousCommand(): Boolean {
|
|
1158
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return true
|
|
983
1159
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
984
1160
|
skipToPreviousInternal()
|
|
985
1161
|
return true
|
|
@@ -990,7 +1166,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
990
1166
|
// no-ops it (Media3 passes C.INDEX_UNSET when the seek implies
|
|
991
1167
|
// no move).
|
|
992
1168
|
if (mediaItemIndex < 0 || mediaItemIndex >= tracks.size) return false
|
|
993
|
-
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
994
1169
|
// Same path as the public skipToIndex: jump the receiver while
|
|
995
1170
|
// casting, else the local engine. Both write currentTrackIndex +
|
|
996
1171
|
// the USER_SKIP_TO_INDEX reason that a raw controller seek skips.
|
|
@@ -1000,21 +1175,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1000
1175
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(mediaItemIndex)) {
|
|
1001
1176
|
return true
|
|
1002
1177
|
}
|
|
1178
|
+
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1003
1179
|
skipToIndexInternal(mediaItemIndex)
|
|
1004
1180
|
return true
|
|
1005
1181
|
}
|
|
1006
1182
|
|
|
1007
1183
|
override fun onMediaPlay() {
|
|
1184
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return
|
|
1008
1185
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1009
1186
|
serviceBinder?.engine?.play()
|
|
1010
1187
|
}
|
|
1011
1188
|
|
|
1012
1189
|
override fun onMediaPause() {
|
|
1190
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePause()) return
|
|
1013
1191
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1014
1192
|
serviceBinder?.engine?.pause()
|
|
1015
1193
|
}
|
|
1016
1194
|
|
|
1017
1195
|
override fun onMediaTogglePlayPause() {
|
|
1196
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeTogglePlayPause()) return
|
|
1018
1197
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1019
1198
|
val engine = serviceBinder?.engine ?: return
|
|
1020
1199
|
// `playWhenReady` (intent to play) is read off the underlying
|
|
@@ -1026,11 +1205,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1026
1205
|
}
|
|
1027
1206
|
|
|
1028
1207
|
override fun onMediaSkipNext() {
|
|
1208
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return
|
|
1029
1209
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1030
1210
|
skipToNextInternal()
|
|
1031
1211
|
}
|
|
1032
1212
|
|
|
1033
1213
|
override fun onMediaSkipPrevious() {
|
|
1214
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return
|
|
1034
1215
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1035
1216
|
skipToPreviousInternal()
|
|
1036
1217
|
}
|
|
@@ -1077,6 +1258,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1077
1258
|
* not a correctness gate.
|
|
1078
1259
|
*/
|
|
1079
1260
|
private fun buildLookaheadStack(config: PlayerConfig, context: Context) {
|
|
1261
|
+
// Ahead of the writer, which captures the monitor's reading at every
|
|
1262
|
+
// reschedule, and a transport flip has to move the window itself. Built
|
|
1263
|
+
// regardless of `enabled` for the same reason the cache is: prefetch can
|
|
1264
|
+
// be turned on at runtime, and the writer built then needs a monitor.
|
|
1265
|
+
cellularTransportMonitor?.stop()
|
|
1266
|
+
cellularTransportMonitor = CellularTransportMonitor(context).apply {
|
|
1267
|
+
// The callback arrives on a ConnectivityManager thread; the reschedule
|
|
1268
|
+
// is main-confined.
|
|
1269
|
+
onTransportChanged = { mainHandler.post { rescheduleLookahead() } }
|
|
1270
|
+
start()
|
|
1271
|
+
}
|
|
1080
1272
|
// Build the cache regardless of `enabled` so caching can be turned on at
|
|
1081
1273
|
// runtime (setLookaheadCacheInternal) without a configure() cycle. Media3's
|
|
1082
1274
|
// SimpleCache fixes its size at construction and allows one instance per
|
|
@@ -1106,6 +1298,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1106
1298
|
null
|
|
1107
1299
|
}
|
|
1108
1300
|
lookaheadCache = cache
|
|
1301
|
+
cache?.servesPlayback = lookaheadConfig.enabled
|
|
1109
1302
|
// Writer + player factory share state via the cache, not via the
|
|
1110
1303
|
// http factory instance. Writer is only useful when there's a
|
|
1111
1304
|
// cache to write into — gate on both `lookaheadConfig.enabled`
|
|
@@ -1119,10 +1312,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1119
1312
|
networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
|
|
1120
1313
|
config.networkTimeoutMs
|
|
1121
1314
|
)
|
|
1122
|
-
)
|
|
1315
|
+
),
|
|
1316
|
+
isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
|
|
1123
1317
|
).apply {
|
|
1124
1318
|
defaultLookaheadCount = lookaheadConfig.lookaheadCount.toInt()
|
|
1125
1319
|
.coerceAtLeast(0)
|
|
1320
|
+
allowsCellularAccess = lookaheadConfig.allowsCellularAccess ?: true
|
|
1126
1321
|
onTrackProcessed = { onMainPostStatusEmit() }
|
|
1127
1322
|
}
|
|
1128
1323
|
} else {
|
|
@@ -1130,20 +1325,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1130
1325
|
}
|
|
1131
1326
|
}
|
|
1132
1327
|
|
|
1133
|
-
/**
|
|
1134
|
-
* Tear down the ExoPlayer + lookahead writer + cache instances so
|
|
1135
|
-
* the configure() rebuild path (headers/userAgent change) can
|
|
1136
|
-
* recreate them cleanly. The on-disk cache directory is preserved
|
|
1137
|
-
* — `SimpleCache.release()` only frees the in-memory instance and
|
|
1138
|
-
* its file lock; the entries stay on disk and the next
|
|
1139
|
-
* `LookaheadCache(...)` constructor reads them back. Mirrors the
|
|
1140
|
-
* SimpleCache "one-instance-per-directory" invariant captured in
|
|
1141
|
-
* NOTES.md.
|
|
1142
|
-
*/
|
|
1143
|
-
private fun destroyPlayerKeepCacheOnDisk() {
|
|
1144
|
-
releasePlayerAndState(notifyTrackChange = true)
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
1328
|
/**
|
|
1148
1329
|
* Lifecycle destroy body. Releases the ExoPlayer instance and
|
|
1149
1330
|
* resets every piece of mutable state so a subsequent configure()
|
|
@@ -1159,7 +1340,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1159
1340
|
// player.
|
|
1160
1341
|
cancelPendingRebind()
|
|
1161
1342
|
serviceBinder?.setCommandHandler(null)
|
|
1162
|
-
releasePlayerAndState(
|
|
1343
|
+
releasePlayerAndState()
|
|
1163
1344
|
boundContext?.let { ctx ->
|
|
1164
1345
|
try {
|
|
1165
1346
|
ctx.unbindService(serviceConnection)
|
|
@@ -1188,25 +1369,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1188
1369
|
pendingStateChangeReason = null
|
|
1189
1370
|
pendingTrackChangeReason = null
|
|
1190
1371
|
lastErrorKey = null
|
|
1372
|
+
cancelPendingRetry()
|
|
1191
1373
|
retryAttemptsRemaining.clear()
|
|
1192
1374
|
if (current === this) current = null
|
|
1193
1375
|
}
|
|
1194
1376
|
|
|
1195
1377
|
/**
|
|
1196
|
-
*
|
|
1197
|
-
* state
|
|
1198
|
-
*
|
|
1199
|
-
* cannot
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
* `(null, -1, QUEUE_REPLACED)` so JS hooks like `useActiveTrack`
|
|
1205
|
-
* clear their snapshot — without it a Player UI rendering
|
|
1206
|
-
* `#${index+1}/${queueLength}` would show `#1/0` after the queue
|
|
1207
|
-
* empties.
|
|
1378
|
+
* Tear-down for the writer, the cache reference and per-position
|
|
1379
|
+
* state, called from [destroyInternal]. The writer's coroutine scope
|
|
1380
|
+
* is cancelled before the cache's MIME store is cleared so a racing
|
|
1381
|
+
* `recordMimeType` cannot land after the clear. The Media3 SimpleCache
|
|
1382
|
+
* is a per-folder process singleton and is not released here —
|
|
1383
|
+
* `LookaheadCache.release()` drops only per-instance state; an
|
|
1384
|
+
* eviction-policy change in [configureInternal] releases and rebuilds
|
|
1385
|
+
* it via [discardCacheDirectory].
|
|
1208
1386
|
*/
|
|
1209
|
-
private fun releasePlayerAndState(
|
|
1387
|
+
private fun releasePlayerAndState() {
|
|
1210
1388
|
// Cancel any armed sleep timer up front — the engine (and its volume) is
|
|
1211
1389
|
// going away, so don't touch it or emit; just stop the tick + reset state.
|
|
1212
1390
|
stopSleepTimerTick()
|
|
@@ -1216,16 +1394,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1216
1394
|
tearDownEventObservers()
|
|
1217
1395
|
lookaheadCacheWriter?.release()
|
|
1218
1396
|
lookaheadCacheWriter = null
|
|
1397
|
+
// Before dropping the reference: the evictor is process-wide and outlives
|
|
1398
|
+
// this player, so its protected set has to be cleared here or those entries
|
|
1399
|
+
// stay unevictable until the next configure moves the window.
|
|
1400
|
+
// `LookaheadCache.release()` does not touch it.
|
|
1401
|
+
lookaheadCache?.setProtectedUrls(emptySet())
|
|
1219
1402
|
lookaheadCache?.release()
|
|
1220
1403
|
lookaheadCache = null
|
|
1404
|
+
cellularTransportMonitor?.stop()
|
|
1405
|
+
cellularTransportMonitor = null
|
|
1221
1406
|
// Mark unconfigured so the [player] accessor returns null even
|
|
1222
1407
|
// though the service may still be bound (it unbinds at the
|
|
1223
1408
|
// bottom of destroyInternal). The PlaybackService releases its
|
|
1224
1409
|
// own engine (which owns the ExoPlayer instances) on service
|
|
1225
1410
|
// unbind / onDestroy.
|
|
1226
1411
|
configured = false
|
|
1227
|
-
val hadTracks = tracks.isNotEmpty()
|
|
1228
1412
|
tracks = emptyList()
|
|
1413
|
+
// The clear emits nothing, so a mirror built from `onQueueChange` deltas
|
|
1414
|
+
// would splice the next delta onto the queue this just dropped. Moving
|
|
1415
|
+
// the revision past the next emit's value is what a subscriber reads as
|
|
1416
|
+
// a missed event, and re-reads.
|
|
1417
|
+
queueRevision += 1
|
|
1229
1418
|
currentTrackIndex = -1
|
|
1230
1419
|
currentTrackSource = null
|
|
1231
1420
|
currentBufferState = BufferState.EMPTY
|
|
@@ -1235,9 +1424,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1235
1424
|
hasStartedPlaying = false
|
|
1236
1425
|
wantsToPlay = false
|
|
1237
1426
|
lastReportedState = PlayerState.NONE
|
|
1238
|
-
if (notifyTrackChange && hadTracks) {
|
|
1239
|
-
trackChangeListeners.forEach { it(null, -1.0, TrackChangeReason.QUEUE_REPLACED, null) }
|
|
1240
|
-
}
|
|
1241
1427
|
refreshNowPlayingFormatForActiveItem()
|
|
1242
1428
|
recomputeCapabilities()
|
|
1243
1429
|
}
|
|
@@ -1284,13 +1470,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1284
1470
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
1285
1471
|
return body()
|
|
1286
1472
|
}
|
|
1287
|
-
val
|
|
1473
|
+
val done = CountDownLatch(1)
|
|
1288
1474
|
var result: Result<T>? = null
|
|
1289
1475
|
mainHandler.post {
|
|
1290
1476
|
result = runCatching { body() }
|
|
1291
|
-
|
|
1477
|
+
done.countDown()
|
|
1292
1478
|
}
|
|
1293
|
-
|
|
1479
|
+
done.await()
|
|
1294
1480
|
return result!!.getOrThrow()
|
|
1295
1481
|
}
|
|
1296
1482
|
|
|
@@ -1406,6 +1592,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1406
1592
|
if (trackIndex < 0 || trackIndex >= engine.allMediaItems.size) return@onMainSync
|
|
1407
1593
|
currentTrackIndex = trackIndex
|
|
1408
1594
|
engine.seekTo(trackIndex, maxOf(0L, positionMs))
|
|
1595
|
+
// The index drifted throughout the cast session without touching the
|
|
1596
|
+
// local prefetch or protection windows, so both are stale for the track
|
|
1597
|
+
// local playback is about to resume on.
|
|
1598
|
+
rescheduleLookahead()
|
|
1599
|
+
recomputeCapabilities()
|
|
1409
1600
|
}
|
|
1410
1601
|
}
|
|
1411
1602
|
|
|
@@ -1450,7 +1641,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1450
1641
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
|
|
1451
1642
|
rescheduleLookahead()
|
|
1452
1643
|
recomputeCapabilities()
|
|
1453
|
-
emitQueueChange(
|
|
1644
|
+
emitQueueChange(
|
|
1645
|
+
QueueChangeReason.SET_QUEUE,
|
|
1646
|
+
inserted = tracks.toTypedArray(),
|
|
1647
|
+
insertedAt = if (tracks.isEmpty()) -1 else 0
|
|
1648
|
+
)
|
|
1454
1649
|
}
|
|
1455
1650
|
|
|
1456
1651
|
/**
|
|
@@ -1480,6 +1675,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1480
1675
|
// last item must not suppress an identical coded error on the
|
|
1481
1676
|
// new queue's first item.
|
|
1482
1677
|
lastErrorKey = null
|
|
1678
|
+
cancelPendingRetry()
|
|
1483
1679
|
// Reset auto-retry counters: new queue → new budget per index.
|
|
1484
1680
|
val attempts = effectiveAutoRetries()
|
|
1485
1681
|
retryAttemptsRemaining =
|
|
@@ -1503,19 +1699,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1503
1699
|
*/
|
|
1504
1700
|
@MainThread
|
|
1505
1701
|
internal fun applyResolvedAssistantQueue(
|
|
1506
|
-
newTracks: List<TrackItem
|
|
1702
|
+
newTracks: List<TrackItem>,
|
|
1703
|
+
append: Boolean = false
|
|
1507
1704
|
): List<MediaItem> {
|
|
1508
|
-
|
|
1509
|
-
|
|
1705
|
+
// `onAddMediaItems` means Media3 will APPEND what this returns, so the
|
|
1706
|
+
// lib's queue has to grow by the same amount. Replacing it here would
|
|
1707
|
+
// leave the player holding old + new while getQueue() reported only the
|
|
1708
|
+
// new tracks, and every index operation after that maps against the
|
|
1709
|
+
// wrong list. `onSetMediaItems` is a genuine replace and keeps that.
|
|
1710
|
+
// Media3 sets the resolved items on the session player after this returns,
|
|
1711
|
+
// which under crossfade addresses the leading leg only. An armed standby
|
|
1712
|
+
// leg still holds the queue as it stood when it was armed, so promoting it
|
|
1713
|
+
// would drop everything added here and auto-advance would stop at the old
|
|
1714
|
+
// queue end. Settle the fade first; the next boundary re-arms from the
|
|
1715
|
+
// updated queue. `CrossfadeEngine.addMediaItems` does this for the paths
|
|
1716
|
+
// that do go through the engine — this is the path that cannot.
|
|
1717
|
+
serviceBinder?.engine?.settlePendingFade()
|
|
1718
|
+
val resolved = if (append) this.tracks + newTracks else newTracks
|
|
1719
|
+
val appendAt = this.tracks.size
|
|
1720
|
+
val previousIndex = this.currentTrackIndex
|
|
1721
|
+
val items = applyTrackListMetadata(resolved) ?: return emptyList()
|
|
1722
|
+
if (append) {
|
|
1723
|
+
// An append does not move the listener off what is playing.
|
|
1724
|
+
this.currentTrackIndex = previousIndex
|
|
1725
|
+
pendingTrackChangeReason = null
|
|
1726
|
+
}
|
|
1727
|
+
serviceBinder?.engine?.setWakeMode(pickWakeMode(resolved))
|
|
1510
1728
|
rescheduleLookahead()
|
|
1511
1729
|
recomputeCapabilities()
|
|
1512
|
-
|
|
1513
|
-
|
|
1730
|
+
// The tracks came from a voice / Android Auto request, so a subscriber has
|
|
1731
|
+
// no other way to learn them — carry them either way.
|
|
1732
|
+
emitQueueChange(
|
|
1733
|
+
if (append) QueueChangeReason.ADD else QueueChangeReason.SET_QUEUE,
|
|
1734
|
+
inserted = if (append) resolved.drop(appendAt).toTypedArray() else resolved.toTypedArray(),
|
|
1735
|
+
insertedAt = if (append) appendAt else if (resolved.isEmpty()) -1 else 0
|
|
1736
|
+
)
|
|
1737
|
+
// Media3 appends only what it is handed back, so return the added slice.
|
|
1738
|
+
return if (append) items.drop(appendAt) else items
|
|
1514
1739
|
}
|
|
1515
1740
|
|
|
1516
1741
|
override fun addToQueue(tracks: Array<TrackItem>, insertBefore: Double?): Promise<Unit> =
|
|
1517
1742
|
Promise.async {
|
|
1518
|
-
// Mirror `seekToInternal
|
|
1743
|
+
// Mirror `seekToInternal`'s non-finite guard: NaN/±Infinity
|
|
1519
1744
|
// from JS would `.toInt()` to MIN/MAX or 0 silently. When
|
|
1520
1745
|
// insertBefore is non-finite, drop it (treat as "append at
|
|
1521
1746
|
// end") rather than silently jumping to position 0.
|
|
@@ -1566,10 +1791,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1566
1791
|
)
|
|
1567
1792
|
|
|
1568
1793
|
// Index-keyed retry-budget map: insertion at `at` shifts every
|
|
1569
|
-
// existing key >= at up by `newTracks.size`.
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1794
|
+
// existing key >= at up by `newTracks.size`. The shift must run in
|
|
1795
|
+
// descending key order: each iteration removes `oldIdx` and writes
|
|
1796
|
+
// `oldIdx + shift`, and in ascending order the write for key k lands
|
|
1797
|
+
// on k + shift, and the later iteration for k + shift removes it, so
|
|
1798
|
+
// that budget is lost.
|
|
1799
|
+
cancelPendingRetry()
|
|
1573
1800
|
val attempts = effectiveAutoRetries()
|
|
1574
1801
|
val shift = newTracks.size
|
|
1575
1802
|
val shifted = retryAttemptsRemaining
|
|
@@ -1602,7 +1829,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1602
1829
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
|
|
1603
1830
|
rescheduleLookahead()
|
|
1604
1831
|
recomputeCapabilities()
|
|
1605
|
-
emitQueueChange(
|
|
1832
|
+
emitQueueChange(
|
|
1833
|
+
QueueChangeReason.ADD,
|
|
1834
|
+
inserted = newTracks.toTypedArray(),
|
|
1835
|
+
insertedAt = at
|
|
1836
|
+
)
|
|
1606
1837
|
return at
|
|
1607
1838
|
}
|
|
1608
1839
|
|
|
@@ -1686,6 +1917,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1686
1917
|
// - **Why not preserve via remap**: requires walking the map
|
|
1687
1918
|
// AND the sanitised-indices in lockstep with offset
|
|
1688
1919
|
// accounting. The clear-and-repopulate is intentional.
|
|
1920
|
+
cancelPendingRetry()
|
|
1689
1921
|
val attempts = effectiveAutoRetries()
|
|
1690
1922
|
retryAttemptsRemaining.clear()
|
|
1691
1923
|
for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
|
|
@@ -1698,7 +1930,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1698
1930
|
recomputeCapabilities()
|
|
1699
1931
|
// Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
|
|
1700
1932
|
// removed; tracks + index are final.
|
|
1701
|
-
emitQueueChange(QueueChangeReason.REMOVE)
|
|
1933
|
+
emitQueueChange(QueueChangeReason.REMOVE, removed = sanitised)
|
|
1702
1934
|
return sanitised
|
|
1703
1935
|
}
|
|
1704
1936
|
|
|
@@ -1709,7 +1941,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1709
1941
|
// mirror the reorder to the receiver. `routeMoveInQueue` is
|
|
1710
1942
|
// suspending so it runs outside `onMain`. Mirrors iOS `moveInQueue`.
|
|
1711
1943
|
val move = onMain {
|
|
1712
|
-
// Mirror `seekToInternal
|
|
1944
|
+
// Mirror `seekToInternal`'s non-finite guard.
|
|
1713
1945
|
if (!fromIndex.isFinite() || !toIndex.isFinite()) return@onMain null
|
|
1714
1946
|
val from = fromIndex.toInt()
|
|
1715
1947
|
val to = toIndex.toInt()
|
|
@@ -1759,6 +1991,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1759
1991
|
// arbitrarily. Cheapest correct path is to clear + repopulate
|
|
1760
1992
|
// (same trade-off as removeFromQueueInternal — structural reset
|
|
1761
1993
|
// semantics for queue mutations).
|
|
1994
|
+
cancelPendingRetry()
|
|
1762
1995
|
val attempts = effectiveAutoRetries()
|
|
1763
1996
|
retryAttemptsRemaining.clear()
|
|
1764
1997
|
for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
|
|
@@ -1766,12 +1999,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1766
1999
|
recomputeCapabilities()
|
|
1767
2000
|
// Reached only past the invalid / `from == to` / pinned-current
|
|
1768
2001
|
// guards, so the order actually changed.
|
|
1769
|
-
emitQueueChange(QueueChangeReason.MOVE)
|
|
2002
|
+
emitQueueChange(QueueChangeReason.MOVE, movedFrom = from, movedTo = to)
|
|
1770
2003
|
return true
|
|
1771
2004
|
}
|
|
1772
2005
|
|
|
1773
|
-
override fun getQueue(): Array<TrackItem
|
|
1774
|
-
tracks.toTypedArray()
|
|
2006
|
+
override fun getQueue(): Promise<Array<TrackItem>> =
|
|
2007
|
+
Promise.async { onMain { tracks.toTypedArray() } }
|
|
1775
2008
|
|
|
1776
2009
|
override fun clearQueue(): Promise<Unit> =
|
|
1777
2010
|
Promise.async {
|
|
@@ -1800,6 +2033,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1800
2033
|
// Reset error-dedup so a subsequent queue's identical-coded error
|
|
1801
2034
|
// isn't suppressed by a stale entry from the cleared queue.
|
|
1802
2035
|
lastErrorKey = null
|
|
2036
|
+
cancelPendingRetry()
|
|
1803
2037
|
retryAttemptsRemaining.clear()
|
|
1804
2038
|
tracks = emptyList()
|
|
1805
2039
|
currentTrackIndex = -1
|
|
@@ -1854,18 +2088,23 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1854
2088
|
// engine. Same shape at every transport entry point — see
|
|
1855
2089
|
// `CastTransportRouter` for the early-return contract.
|
|
1856
2090
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
|
|
1857
|
-
// Stuck-recovery: if the user re-taps play after
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
//
|
|
1861
|
-
// Mirrors iOS `play()` stuck-recovery.
|
|
2091
|
+
// Stuck-recovery: if the user re-taps play after an exhausted-retries
|
|
2092
|
+
// error, hand the track back the allowance the consumer configured and
|
|
2093
|
+
// re-prepare. Not a hard-coded 1: a consumer who set `autoRetries: 0`
|
|
2094
|
+
// asked for none. Mirrors iOS `play()` stuck-recovery.
|
|
1862
2095
|
if (lastReportedState == PlayerState.ERROR &&
|
|
1863
2096
|
currentTrackIndex >= 0 &&
|
|
1864
2097
|
(retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
|
|
1865
|
-
retryAttemptsRemaining[currentTrackIndex] =
|
|
2098
|
+
retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
|
|
1866
2099
|
lastErrorKey = null
|
|
1867
2100
|
player?.prepare()
|
|
1868
2101
|
}
|
|
2102
|
+
// At the end of the queue there is nothing to play: ExoPlayer stays
|
|
2103
|
+
// STATE_ENDED through play(), and a playWhenReady left set would
|
|
2104
|
+
// start the next track a skip seats without anyone asking. A skip, a
|
|
2105
|
+
// seek or a fresh queue leaves this state; play() alone restarts
|
|
2106
|
+
// nothing — the same contract as iOS.
|
|
2107
|
+
if (player?.playbackState == Player.STATE_ENDED && tracks.isNotEmpty()) return@onMain
|
|
1869
2108
|
// Stash USER on the pending-reason channel so the
|
|
1870
2109
|
// subsequent Player.Listener fire attributes the transition
|
|
1871
2110
|
// correctly.
|
|
@@ -1882,14 +2121,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1882
2121
|
// During cast the receiver owns playback — forward a play so a failed
|
|
1883
2122
|
// receiver item re-attempts.
|
|
1884
2123
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
1890
|
-
//
|
|
1891
|
-
|
|
1892
|
-
|
|
2124
|
+
// A user asking for a retry has a reason, and the library is not the
|
|
2125
|
+
// one to judge it. This is the manual attempt once the automatic one
|
|
2126
|
+
// is spent, so gating it on the player's state would gate recovery on
|
|
2127
|
+
// the state of a player that has already failed to recover — and a
|
|
2128
|
+
// wedged decoder still reporting PLAYING is exactly when someone
|
|
2129
|
+
// reaches for the button. retryFailedItem below discards the wedged
|
|
2130
|
+
// DataSource (a bare prepare() would re-use it), restores the
|
|
2131
|
+
// pre-failure position, and resumes playing.
|
|
2132
|
+
//
|
|
2133
|
+
// Only the bounds remain: no current track is genuinely nothing to
|
|
2134
|
+
// retry, which is not second-guessing the request.
|
|
2135
|
+
if (currentTrackIndex < 0) return@onMain
|
|
2136
|
+
// The configured budget, not a hard-coded 1: this restores the track's
|
|
2137
|
+
// automatic allowance for whatever fails *next*, and a consumer who
|
|
2138
|
+
// set `autoRetries: 0` asked for none.
|
|
2139
|
+
retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
|
|
1893
2140
|
lastErrorKey = null
|
|
1894
2141
|
pendingStateChangeReason = StateChangeReason.USER
|
|
1895
2142
|
wantsToPlay = true
|
|
@@ -2096,7 +2343,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2096
2343
|
override fun skipToIndex(index: Double): Promise<Unit> =
|
|
2097
2344
|
Promise.async {
|
|
2098
2345
|
onMain {
|
|
2099
|
-
// Mirror `seekToInternal
|
|
2346
|
+
// Mirror `seekToInternal`'s non-finite guard. Without this
|
|
2100
2347
|
// `Double.NaN.toInt()` returns 0 — `skipToIndex(NaN)` would
|
|
2101
2348
|
// silently jump to track 0 instead of no-op'ing. iOS uses
|
|
2102
2349
|
// `InputGuards.validQueueIndex` for the same guarantee.
|
|
@@ -2228,14 +2475,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2228
2475
|
repeatMode = repeatModeAsSkipMode()
|
|
2229
2476
|
)
|
|
2230
2477
|
|
|
2231
|
-
@VisibleForTesting
|
|
2232
|
-
internal fun computePreviousIndex(): Int? =
|
|
2233
|
-
QueueSkipArithmetic.computePrevious(
|
|
2234
|
-
trackCount = tracks.size,
|
|
2235
|
-
currentIndex = currentTrackIndex,
|
|
2236
|
-
repeatMode = repeatModeAsSkipMode()
|
|
2237
|
-
)
|
|
2238
|
-
|
|
2239
2478
|
private fun repeatModeAsSkipMode(): QueueSkipRepeatMode = when (repeatModeState) {
|
|
2240
2479
|
RepeatMode.OFF -> QueueSkipRepeatMode.OFF
|
|
2241
2480
|
RepeatMode.TRACK -> QueueSkipRepeatMode.TRACK
|
|
@@ -2267,7 +2506,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2267
2506
|
* clearQueueInternal, skipToIndexInternal / skipToNextInternal /
|
|
2268
2507
|
* skipToPreviousInternal (all index writes), setRepeatModeInternal,
|
|
2269
2508
|
* shuffleQueueInternal, handleMediaItemTransition (covers Media3
|
|
2270
|
-
* REASON_AUTO + REASON_REPEAT auto-advance),
|
|
2509
|
+
* REASON_AUTO + REASON_REPEAT auto-advance), the crossfade
|
|
2510
|
+
* fade-start / cancel index writes, emitRemoteTrackChange and
|
|
2511
|
+
* resumeLocalAfterCast (cast), destroyInternal.
|
|
2271
2512
|
*/
|
|
2272
2513
|
@MainThread
|
|
2273
2514
|
internal fun recomputeCapabilities() {
|
|
@@ -2458,6 +2699,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2458
2699
|
newEngine.setVolume(volumeState)
|
|
2459
2700
|
newEngine.setPlaybackSpeed(playbackSpeedState)
|
|
2460
2701
|
newEngine.setPitchCorrectionMode(pitchCorrectionModeState)
|
|
2702
|
+
installSuppliedReplayGainResolver(newEngine)
|
|
2461
2703
|
// Drive the AirPlay receiver-display metadata sync. When the new engine is
|
|
2462
2704
|
// the dedicated AirPlay engine, hand the now-active session this engine's
|
|
2463
2705
|
// ExoPlayer — registered in AirPlayEngine.init, so CastSinkRouter.player()
|
|
@@ -2477,7 +2719,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2477
2719
|
override fun setPlaybackSpeed(rate: Double): Promise<Unit> =
|
|
2478
2720
|
Promise.async {
|
|
2479
2721
|
onMain {
|
|
2480
|
-
// Mirror `seekToInternal
|
|
2722
|
+
// Mirror `seekToInternal`'s non-finite guard. NaN →
|
|
2481
2723
|
// undefined ExoPlayer audio behaviour at the AudioSink;
|
|
2482
2724
|
// ±Infinity → IllegalArgumentException from
|
|
2483
2725
|
// `PlaybackParameters` constructor.
|
|
@@ -2525,7 +2767,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2525
2767
|
override fun setVolume(volume: Double): Promise<Unit> =
|
|
2526
2768
|
Promise.async {
|
|
2527
2769
|
onMain {
|
|
2528
|
-
// Mirror `seekToInternal
|
|
2770
|
+
// Mirror `seekToInternal`'s non-finite guard. NaN →
|
|
2529
2771
|
// undefined audio output; ±Infinity → ditto.
|
|
2530
2772
|
if (!volume.isFinite()) return@onMain
|
|
2531
2773
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetVolume(volume.toFloat())) return@onMain
|
|
@@ -2828,13 +3070,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2828
3070
|
// resume after the rebuild.
|
|
2829
3071
|
val wasPlaying = p.isPlaying
|
|
2830
3072
|
|
|
2831
|
-
//
|
|
2832
|
-
|
|
3073
|
+
// Shuffle positions rather than tracks: the permutation is what
|
|
3074
|
+
// onQueueChange reports, and it cannot be recovered from the reordered
|
|
3075
|
+
// list afterwards. Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
|
|
3076
|
+
val order = tracks.indices.shuffled()
|
|
3077
|
+
val newTracks = order.map { tracks[it] }
|
|
2833
3078
|
|
|
2834
3079
|
this.tracks = newTracks
|
|
2835
3080
|
this.currentTrackIndex = 0
|
|
2836
3081
|
pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
|
|
2837
3082
|
// Destructive shuffle replaces every position; reset retry budget.
|
|
3083
|
+
cancelPendingRetry()
|
|
2838
3084
|
val attempts = effectiveAutoRetries()
|
|
2839
3085
|
retryAttemptsRemaining.clear()
|
|
2840
3086
|
for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
|
|
@@ -2852,7 +3098,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2852
3098
|
rescheduleLookahead()
|
|
2853
3099
|
recomputeCapabilities()
|
|
2854
3100
|
// Skip a single-track shuffle — the order can't change.
|
|
2855
|
-
if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
|
|
3101
|
+
if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE, order = order)
|
|
2856
3102
|
|
|
2857
3103
|
return ShuffleResult(
|
|
2858
3104
|
tracks = newTracks.toTypedArray(),
|
|
@@ -2875,13 +3121,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2875
3121
|
// goes away.
|
|
2876
3122
|
|
|
2877
3123
|
/**
|
|
2878
|
-
* Snapshot of the
|
|
2879
|
-
*
|
|
2880
|
-
*
|
|
2881
|
-
*
|
|
3124
|
+
* Snapshot of the active playback source's state mapped onto the public
|
|
3125
|
+
* [PlayerState] enum — the cast receiver while a session owns playback,
|
|
3126
|
+
* otherwise the local ExoPlayer. Safe to call from any thread: the receiver
|
|
3127
|
+
* read is lock-free, and the local read hops to main via [syncMain] so
|
|
3128
|
+
* Media3's thread-assertion does not fire.
|
|
2882
3129
|
*/
|
|
2883
|
-
override fun getState(): PlayerState
|
|
2884
|
-
|
|
3130
|
+
override fun getState(): PlayerState {
|
|
3131
|
+
// The receiver owns playback during a cast session — the local player is
|
|
3132
|
+
// parked and paused for its duration, so its derived state describes
|
|
3133
|
+
// nothing the user can hear. Until the receiver has reported (session
|
|
3134
|
+
// still activating, holding its initial IDLE) the local read is the
|
|
3135
|
+
// better answer; after that the receiver is authoritative, including a
|
|
3136
|
+
// later IDLE, which means it genuinely has nothing loaded.
|
|
3137
|
+
val remote = com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote()
|
|
3138
|
+
if (remote != null && remote.hasReportedState) {
|
|
3139
|
+
return com.margelo.nitro.queueplayer.cast.mapRemotePlaybackState(remote.state.value)
|
|
3140
|
+
}
|
|
3141
|
+
return syncMain { getStateInternal() }
|
|
3142
|
+
}
|
|
2885
3143
|
|
|
2886
3144
|
@VisibleForTesting
|
|
2887
3145
|
internal fun getStateInternal(): PlayerState {
|
|
@@ -2896,11 +3154,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2896
3154
|
)
|
|
2897
3155
|
}
|
|
2898
3156
|
|
|
2899
|
-
// No `syncMain` hop on the next
|
|
3157
|
+
// No `syncMain` hop on the next five getters: each reads a single
|
|
2900
3158
|
// `@Volatile` field that is only written on main, so the JVM
|
|
2901
3159
|
// memory model gives the cross-thread happens-before edge for
|
|
2902
|
-
// free. iOS
|
|
2903
|
-
//
|
|
3160
|
+
// free. iOS hops through `onPlayerQueueSync` instead: its state is
|
|
3161
|
+
// confined to `playerQueue`, and Swift has no `@Volatile`-equivalent
|
|
3162
|
+
// storage class.
|
|
2904
3163
|
override fun getCurrentTrackIndex(): Double = currentTrackIndex.toDouble()
|
|
2905
3164
|
|
|
2906
3165
|
override fun getCurrentTrackSource(): TrackSource? = currentTrackSource
|
|
@@ -2944,26 +3203,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2944
3203
|
}
|
|
2945
3204
|
|
|
2946
3205
|
override fun onQueueChange(
|
|
2947
|
-
callback: (
|
|
3206
|
+
callback: (delta: QueueChangeDelta, currentIndex: Double, reason: QueueChangeReason) -> Unit
|
|
2948
3207
|
): () -> Unit {
|
|
2949
3208
|
val id = queueChangeListeners.add(callback)
|
|
2950
3209
|
return { queueChangeListeners.remove(id) }
|
|
2951
3210
|
}
|
|
2952
3211
|
|
|
2953
3212
|
/**
|
|
2954
|
-
*
|
|
2955
|
-
* queue
|
|
2956
|
-
*
|
|
2957
|
-
*
|
|
2958
|
-
*
|
|
3213
|
+
* Emit a queue change as a description of what happened rather than a copy of
|
|
3214
|
+
* the queue. Callers pass only the fields their mutation defines; the rest
|
|
3215
|
+
* stay empty, and `revision` lets a subscriber notice a dropped event.
|
|
3216
|
+
* Main-thread only — every queue mutation commits on main, so [tracks] /
|
|
3217
|
+
* [currentTrackIndex] are settled here. Callers gate this on an actual
|
|
3218
|
+
* content/order change so a no-op mutation never emits.
|
|
2959
3219
|
*/
|
|
2960
3220
|
@MainThread
|
|
2961
|
-
private fun emitQueueChange(
|
|
2962
|
-
|
|
3221
|
+
private fun emitQueueChange(
|
|
3222
|
+
reason: QueueChangeReason,
|
|
3223
|
+
inserted: Array<TrackItem> = emptyArray(),
|
|
3224
|
+
insertedAt: Int = -1,
|
|
3225
|
+
removed: List<Int> = emptyList(),
|
|
3226
|
+
movedFrom: Int = -1,
|
|
3227
|
+
movedTo: Int = -1,
|
|
3228
|
+
order: List<Int> = emptyList()
|
|
3229
|
+
) {
|
|
3230
|
+
queueRevision += 1
|
|
3231
|
+
val delta = QueueChangeDelta(
|
|
3232
|
+
revision = queueRevision.toDouble(),
|
|
3233
|
+
length = tracks.size.toDouble(),
|
|
3234
|
+
inserted = inserted,
|
|
3235
|
+
insertedAt = insertedAt.toDouble(),
|
|
3236
|
+
removed = removed.map { it.toDouble() }.toDoubleArray(),
|
|
3237
|
+
movedFrom = movedFrom.toDouble(),
|
|
3238
|
+
movedTo = movedTo.toDouble(),
|
|
3239
|
+
order = order.map { it.toDouble() }.toDoubleArray()
|
|
3240
|
+
)
|
|
2963
3241
|
val index = currentTrackIndex.toDouble()
|
|
2964
|
-
queueChangeListeners.forEach { it(
|
|
3242
|
+
queueChangeListeners.forEach { it(delta, index, reason) }
|
|
2965
3243
|
}
|
|
2966
3244
|
|
|
3245
|
+
/** Increments once per queue-change emit; a gap tells a subscriber it missed one. */
|
|
3246
|
+
private var queueRevision: Long = 0
|
|
3247
|
+
|
|
2967
3248
|
override fun onProgress(
|
|
2968
3249
|
callback: (progress: PlayerProgress) -> Unit
|
|
2969
3250
|
): () -> Unit {
|
|
@@ -3125,15 +3406,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3125
3406
|
* drained). Post-bind: registers immediately. Either way the
|
|
3126
3407
|
* returned disposer matches the consumer's expectation that
|
|
3127
3408
|
* calling it tears the registration down regardless of timing.
|
|
3128
|
-
*
|
|
3129
|
-
*
|
|
3130
|
-
*
|
|
3131
|
-
*
|
|
3132
|
-
*
|
|
3133
|
-
* every registration; the carService thought it had wired up but
|
|
3134
|
-
* the lib had nothing recorded, so Android Auto's later bind hit
|
|
3135
|
-
* `dispatchCarConnect` with a null callback + timed out → AA
|
|
3136
|
-
* "No items".
|
|
3409
|
+
* `registerPlaybackService` may run before `configure()` resolves, and
|
|
3410
|
+
* [serviceBinder] is null again between an OS service kill and the
|
|
3411
|
+
* rebind (`onServiceDisconnected`); both cases queue here and are
|
|
3412
|
+
* applied by [drainPendingBrowseRegistrations] on the next
|
|
3413
|
+
* `onServiceConnected`.
|
|
3137
3414
|
*/
|
|
3138
3415
|
private fun deferredRegister(
|
|
3139
3416
|
register: (BrowseCallbackRegistry) -> () -> Unit
|
|
@@ -3179,14 +3456,39 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3179
3456
|
}
|
|
3180
3457
|
}
|
|
3181
3458
|
|
|
3182
|
-
|
|
3183
|
-
|
|
3459
|
+
/**
|
|
3460
|
+
* Asynchronous because the snapshot counts fully-cached entries, which walks
|
|
3461
|
+
* every cache key and takes the cache's monitor per key — contended against
|
|
3462
|
+
* the prefetch thread. Resolved off both the JS thread and the app looper;
|
|
3463
|
+
* every field it reads is published for that.
|
|
3464
|
+
*/
|
|
3465
|
+
override fun getLookaheadCacheStatus(): Promise<CacheStatus> =
|
|
3466
|
+
Promise.async { buildCacheStatusSnapshot() }
|
|
3184
3467
|
|
|
3468
|
+
/**
|
|
3469
|
+
* Wipe the cache and resolve once it is actually gone.
|
|
3470
|
+
*
|
|
3471
|
+
* The delete is file IO, so it runs off the app looper; the promise resolves
|
|
3472
|
+
* after it, because a consumer that awaits this and then reads
|
|
3473
|
+
* `getLookaheadCacheStatus()` must not see the pre-wipe size.
|
|
3474
|
+
*/
|
|
3185
3475
|
override fun clearLookaheadCache(): Promise<Unit> =
|
|
3186
|
-
Promise.async {
|
|
3476
|
+
Promise.async {
|
|
3477
|
+
onMain { clearLookaheadCacheInternal() }?.join()
|
|
3478
|
+
onMain { emitCacheStatus() }
|
|
3479
|
+
}
|
|
3187
3480
|
|
|
3188
3481
|
override fun onCacheStatusChange(callback: (status: CacheStatus) -> Unit): () -> Unit {
|
|
3189
3482
|
val id = cacheStatusListeners.add(callback)
|
|
3483
|
+
// Deliver the current status once. Otherwise a settings screen shows its
|
|
3484
|
+
// placeholder until the next prefetch completes — which never happens for a
|
|
3485
|
+
// fully-cached queue or a consumer running with prefetch off.
|
|
3486
|
+
cacheIoScope.launch(cacheStatusDispatcher) {
|
|
3487
|
+
val snapshot = buildCacheStatusSnapshot()
|
|
3488
|
+
mainHandler.post {
|
|
3489
|
+
if (cacheStatusListeners.contains(id)) callback(snapshot)
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3190
3492
|
return { cacheStatusListeners.remove(id) }
|
|
3191
3493
|
}
|
|
3192
3494
|
|
|
@@ -3243,6 +3545,30 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3243
3545
|
|
|
3244
3546
|
override fun getReplayGainMode(): ReplayGainMode = ReplayGainConfig.getMode()
|
|
3245
3547
|
|
|
3548
|
+
/**
|
|
3549
|
+
* Hand a freshly-installed engine the reader for consumer-supplied
|
|
3550
|
+
* ReplayGain. The engine holds no queue metadata of its own — it
|
|
3551
|
+
* plays `MediaItem`s — so the lookup from a queue index back to the
|
|
3552
|
+
* consumer's `TrackItem` lives here, where the canonical [tracks]
|
|
3553
|
+
* list does.
|
|
3554
|
+
*
|
|
3555
|
+
* The lambda reads [tracks] on every invocation rather than closing
|
|
3556
|
+
* over a snapshot, so a `setQueue` carrying corrected values is
|
|
3557
|
+
* picked up by the next recompute. Called wherever an engine is
|
|
3558
|
+
* installed — the configure-time rebuild and the playback-mode swap —
|
|
3559
|
+
* because a fresh engine starts with no resolver.
|
|
3560
|
+
*/
|
|
3561
|
+
private fun installSuppliedReplayGainResolver(engine: PlaybackEngine?) {
|
|
3562
|
+
val resolver: (Int) -> SuppliedReplayGain? = { index ->
|
|
3563
|
+
tracks.getOrNull(index)?.let { SuppliedReplayGain.from(it) }
|
|
3564
|
+
}
|
|
3565
|
+
when (engine) {
|
|
3566
|
+
is GaplessEngine -> engine.suppliedReplayGainResolver = resolver
|
|
3567
|
+
is CrossfadeEngine -> engine.suppliedReplayGainResolver = resolver
|
|
3568
|
+
else -> Unit
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3246
3572
|
/**
|
|
3247
3573
|
* Re-resolve the active item's format and emit if it differs from
|
|
3248
3574
|
* the last cached value. Call sites: [Player.Listener.onMediaItemTransition]
|
|
@@ -3302,14 +3628,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3302
3628
|
* live — no configure() cycle needed:
|
|
3303
3629
|
*
|
|
3304
3630
|
* - `enabled` toggle:
|
|
3305
|
-
* - true → false: tears down the WRITER (no more proactive
|
|
3306
|
-
*
|
|
3307
|
-
*
|
|
3308
|
-
*
|
|
3309
|
-
*
|
|
3631
|
+
* - true → false: tears down the WRITER (no more proactive prefetch)
|
|
3632
|
+
* and routes the player's reads to the origin from the next item
|
|
3633
|
+
* load; the item already loading keeps the source it opened. The
|
|
3634
|
+
* cache OBJECT stays alive (Media3 allows one per directory) but is
|
|
3635
|
+
* neither read nor written while disabled.
|
|
3310
3636
|
* - false → true: rebuilds the writer against the existing cache
|
|
3311
|
-
* (the cache is built at configure() regardless of `enabled`)
|
|
3312
|
-
* so caching turns on without a
|
|
3637
|
+
* (the cache is built at configure() regardless of `enabled`) and
|
|
3638
|
+
* routes reads through it again, so caching turns on without a
|
|
3639
|
+
* configure() cycle.
|
|
3313
3640
|
*
|
|
3314
3641
|
* - `lookaheadCount` change: live update via
|
|
3315
3642
|
* `LookaheadCacheWriter.defaultLookaheadCount`. Applies on the
|
|
@@ -3331,8 +3658,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3331
3658
|
|
|
3332
3659
|
val countChanged = newConfig.lookaheadCount != previous.lookaheadCount
|
|
3333
3660
|
val enableChanged = newConfig.enabled != previous.enabled
|
|
3334
|
-
|
|
3335
|
-
|
|
3661
|
+
val cellularChanged =
|
|
3662
|
+
(newConfig.allowsCellularAccess ?: true) != (previous.allowsCellularAccess ?: true)
|
|
3663
|
+
|
|
3664
|
+
// The player's reads follow the flag from the next item load: disabled
|
|
3665
|
+
// means the origin, whatever the disk holds. The cache object stays —
|
|
3666
|
+
// Media3 allows one instance per directory — but nothing reads or writes
|
|
3667
|
+
// it until it is enabled again.
|
|
3668
|
+
if (enableChanged) {
|
|
3669
|
+
lookaheadCache?.servesPlayback = newConfig.enabled
|
|
3670
|
+
}
|
|
3671
|
+
// Disable → tear down the writer.
|
|
3336
3672
|
if (enableChanged && !newConfig.enabled) {
|
|
3337
3673
|
lookaheadCacheWriter?.release()
|
|
3338
3674
|
lookaheadCacheWriter = null
|
|
@@ -3353,51 +3689,80 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3353
3689
|
networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
|
|
3354
3690
|
config.networkTimeoutMs
|
|
3355
3691
|
)
|
|
3356
|
-
)
|
|
3692
|
+
),
|
|
3693
|
+
isOnCellular = { cellularTransportMonitor?.isOnCellular == true }
|
|
3357
3694
|
).apply {
|
|
3358
3695
|
defaultLookaheadCount =
|
|
3359
3696
|
newConfig.lookaheadCount.toInt().coerceAtLeast(0)
|
|
3697
|
+
allowsCellularAccess = newConfig.allowsCellularAccess ?: true
|
|
3360
3698
|
onTrackProcessed = { onMainPostStatusEmit() }
|
|
3361
3699
|
}
|
|
3362
3700
|
rescheduleLookahead()
|
|
3363
3701
|
}
|
|
3364
3702
|
}
|
|
3365
3703
|
|
|
3366
|
-
// Live
|
|
3367
|
-
// the
|
|
3368
|
-
//
|
|
3704
|
+
// Live policy update — safe regardless of player lifecycle. `lookaheadCount`
|
|
3705
|
+
// resizes the window; `allowsCellularAccess` decides whether the window is
|
|
3706
|
+
// fetched at all, so turning it off empties it and cancels the prefetch in
|
|
3707
|
+
// flight and turning it back on refills it. Both take effect on the single
|
|
3708
|
+
// reschedule below. Skipped when the enable branch above just rebuilt the
|
|
3709
|
+
// writer, which already applied both values and rescheduled.
|
|
3369
3710
|
val writer = lookaheadCacheWriter
|
|
3370
|
-
if (
|
|
3371
|
-
|
|
3372
|
-
|
|
3711
|
+
if (!enableChanged && writer != null && (countChanged || cellularChanged)) {
|
|
3712
|
+
if (countChanged) {
|
|
3713
|
+
writer.defaultLookaheadCount =
|
|
3714
|
+
newConfig.lookaheadCount.toInt().coerceAtLeast(0)
|
|
3715
|
+
}
|
|
3716
|
+
if (cellularChanged) {
|
|
3717
|
+
writer.allowsCellularAccess = newConfig.allowsCellularAccess ?: true
|
|
3718
|
+
}
|
|
3373
3719
|
rescheduleLookahead()
|
|
3374
3720
|
}
|
|
3375
3721
|
|
|
3722
|
+
// `lookaheadCount` sizes the protected window, so a change to it must move
|
|
3723
|
+
// the window even when the branches above did not reschedule — prefetch
|
|
3724
|
+
// disabled leaves no writer, and a combined disable+count change skips the
|
|
3725
|
+
// count branch entirely. The cache still evicts in both cases.
|
|
3726
|
+
refreshCacheProtection()
|
|
3376
3727
|
emitCacheStatus()
|
|
3377
3728
|
}
|
|
3378
3729
|
|
|
3730
|
+
/**
|
|
3731
|
+
* Cancel the prefetch and wipe the cache, returning the job doing the file
|
|
3732
|
+
* work so a caller can await it. Null when there is no cache to clear.
|
|
3733
|
+
*
|
|
3734
|
+
* The wipe is a per-resource delete walk, so it runs off the app looper.
|
|
3735
|
+
* Cancelling the prefetch first is what keeps it from racing: a `CacheWriter`
|
|
3736
|
+
* mid-write into a key just removed re-creates the entry seconds later.
|
|
3737
|
+
*
|
|
3738
|
+
* Deliberately does NOT call [rescheduleLookahead] — an immediate refill makes
|
|
3739
|
+
* `clearLookaheadCache` impossible to observe at zero from JS, and "clear"
|
|
3740
|
+
* means "wipe and stop" (the consumer re-triggers prefetch via
|
|
3741
|
+
* setLookaheadCache, skipToIndex, or setQueue). Matches iOS.
|
|
3742
|
+
*/
|
|
3743
|
+
@MainThread
|
|
3379
3744
|
@VisibleForTesting
|
|
3380
|
-
internal fun clearLookaheadCacheInternal() {
|
|
3381
|
-
val cache = lookaheadCache ?: return
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
//
|
|
3385
|
-
// call rescheduleLookahead() here — an immediate refill makes
|
|
3386
|
-
// `clearLookaheadCache` impossible to observe at zero from JS,
|
|
3387
|
-
// and "clear" semantically means "wipe and stop" (consumer can
|
|
3388
|
-
// re-trigger prefetch via setLookaheadCache, skipToIndex, or
|
|
3389
|
-
// setQueue). Matches iOS.
|
|
3745
|
+
internal fun clearLookaheadCacheInternal(): Job? {
|
|
3746
|
+
val cache = lookaheadCache ?: return null
|
|
3747
|
+
// Stops the blocking CacheWriter too, not just the coroutine. Its cancelled
|
|
3748
|
+
// flag is polled between chunk reads, so a span already mid-write can still
|
|
3749
|
+
// land after the wipe; this closes the window rather than eliminating it.
|
|
3390
3750
|
lookaheadCacheWriter?.cancel()
|
|
3391
|
-
|
|
3392
|
-
|
|
3751
|
+
// File work only. Emitting from in here would make the job depend on the
|
|
3752
|
+
// main thread, which deadlocks anyone awaiting it with a blocking wait on
|
|
3753
|
+
// that thread. The caller emits instead.
|
|
3754
|
+
return cacheIoScope.launch { cache.clear() }
|
|
3393
3755
|
}
|
|
3394
3756
|
|
|
3395
3757
|
/**
|
|
3396
3758
|
* Build a [CacheStatus] snapshot from the current cache + writer
|
|
3397
|
-
* state.
|
|
3398
|
-
*
|
|
3759
|
+
* state. Called off the app looper — from the `Promise.async` pool
|
|
3760
|
+
* (`getLookaheadCacheStatus`) and the serialised cache-status IO
|
|
3761
|
+
* dispatcher (`emitCacheStatus` / `onCacheStatusChange`); reads
|
|
3762
|
+
* `lookaheadCache` / `lookaheadCacheWriter` as @Volatile snapshots.
|
|
3399
3763
|
*/
|
|
3400
|
-
|
|
3764
|
+
@VisibleForTesting
|
|
3765
|
+
internal fun buildCacheStatusSnapshot(): CacheStatus {
|
|
3401
3766
|
val cache = lookaheadCache
|
|
3402
3767
|
val writer = lookaheadCacheWriter
|
|
3403
3768
|
val currentSizeBytes = cache?.currentSizeBytes() ?: 0L
|
|
@@ -3420,21 +3785,32 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3420
3785
|
)
|
|
3421
3786
|
}
|
|
3422
3787
|
|
|
3423
|
-
/**
|
|
3788
|
+
/**
|
|
3789
|
+
* Fire the cache-status listeners with a fresh snapshot. Called from main
|
|
3790
|
+
* and from the writer's IO thread (`onTrackProcessed`); the registry is
|
|
3791
|
+
* copy-on-write and the listeners run on main either way.
|
|
3792
|
+
*/
|
|
3424
3793
|
private fun emitCacheStatus() {
|
|
3425
3794
|
if (cacheStatusListeners.isEmpty) return
|
|
3426
|
-
|
|
3427
|
-
|
|
3795
|
+
// Built off the app looper. The snapshot counts fully-cached entries,
|
|
3796
|
+
// which walks every cache key and takes the cache's monitor per key —
|
|
3797
|
+
// contended against the prefetch thread that is committing downloads —
|
|
3798
|
+
// and this fires after every prefetch iteration whenever a consumer is
|
|
3799
|
+
// subscribed. Listeners are still called on the looper they expect.
|
|
3800
|
+
cacheIoScope.launch(cacheStatusDispatcher) {
|
|
3801
|
+
val snapshot = buildCacheStatusSnapshot()
|
|
3802
|
+
mainHandler.post { cacheStatusListeners.forEach { it(snapshot) } }
|
|
3803
|
+
}
|
|
3428
3804
|
}
|
|
3429
3805
|
|
|
3430
3806
|
/**
|
|
3431
|
-
*
|
|
3432
|
-
*
|
|
3433
|
-
* always dispatched on main even though the writer's coroutine
|
|
3434
|
-
* runs on Dispatchers.IO.
|
|
3807
|
+
* Emit a cache-status snapshot. Bound to the writer's `onTrackProcessed`
|
|
3808
|
+
* hook, so it fires once per prefetched track.
|
|
3435
3809
|
*/
|
|
3436
3810
|
private fun onMainPostStatusEmit() {
|
|
3437
|
-
|
|
3811
|
+
// `emitCacheStatus` does its own hopping — off the looper to build, back
|
|
3812
|
+
// onto it to deliver — so there is nothing to post first.
|
|
3813
|
+
emitCacheStatus()
|
|
3438
3814
|
}
|
|
3439
3815
|
|
|
3440
3816
|
// --- Events — observer wiring + dispatch
|
|
@@ -3465,12 +3841,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3465
3841
|
}
|
|
3466
3842
|
|
|
3467
3843
|
private fun tearDownEventObservers() {
|
|
3844
|
+
// A retry posted before teardown would otherwise land on a released engine.
|
|
3845
|
+
// Cancelled here rather than in stopProgressTick, which also runs when the
|
|
3846
|
+
// last progress subscriber unsubscribes — a screen unmounting must not kill
|
|
3847
|
+
// an in-flight retry.
|
|
3848
|
+
cancelPendingRetry()
|
|
3468
3849
|
// Clear the delegate from the engine TrackPlayer last wired
|
|
3469
3850
|
// itself onto, even if [serviceBinder] has been re-bound to a
|
|
3470
3851
|
// fresh engine since.
|
|
3471
3852
|
engineDelegateRegistration?.let { engine ->
|
|
3472
3853
|
if (engine.delegate === this) engine.delegate = null
|
|
3473
3854
|
}
|
|
3855
|
+
// And from whatever engine is bound now. A playback-mode swap carries the
|
|
3856
|
+
// delegate across to the incoming engine without this registration
|
|
3857
|
+
// following it, so clearing only the remembered one unwires an engine
|
|
3858
|
+
// that has already been released and leaves the live one still
|
|
3859
|
+
// delivering into a torn-down player.
|
|
3860
|
+
serviceBinder?.engine?.let { engine ->
|
|
3861
|
+
if (engine.delegate === this) engine.delegate = null
|
|
3862
|
+
}
|
|
3474
3863
|
engineDelegateRegistration = null
|
|
3475
3864
|
stopProgressTick()
|
|
3476
3865
|
}
|
|
@@ -3544,8 +3933,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3544
3933
|
p.replaceMediaItem(idx, updated)
|
|
3545
3934
|
}
|
|
3546
3935
|
|
|
3547
|
-
override fun onError(
|
|
3548
|
-
|
|
3936
|
+
override fun onError(
|
|
3937
|
+
engine: PlaybackEngine, exception: PlaybackException, failedIndex: Int
|
|
3938
|
+
) {
|
|
3939
|
+
handlePlayerError(exception, failedIndex)
|
|
3549
3940
|
}
|
|
3550
3941
|
|
|
3551
3942
|
override fun onPlaybackEnded(engine: PlaybackEngine) {
|
|
@@ -3571,12 +3962,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3571
3962
|
currentTrackIndex = incomingIndex
|
|
3572
3963
|
lastErrorKey = null
|
|
3573
3964
|
val track = tracks[incomingIndex]
|
|
3574
|
-
|
|
3575
|
-
currentTrackSource = classifyTrackSource(track.url) { url ->
|
|
3576
|
-
cache?.isFullyCached(url) == true
|
|
3577
|
-
}
|
|
3965
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3578
3966
|
crossfadeFadeStartEmittedIdx = incomingIndex
|
|
3579
3967
|
pendingTrackChangeReason = null
|
|
3968
|
+
logPlaybackSource(track.url)
|
|
3580
3969
|
// New active track => new milestone playthrough (the matching
|
|
3581
3970
|
// structural transition echo is deduped in handleMediaItemTransition).
|
|
3582
3971
|
milestoneTracker.reset()
|
|
@@ -3588,6 +3977,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3588
3977
|
rescheduleLookahead()
|
|
3589
3978
|
}
|
|
3590
3979
|
|
|
3980
|
+
/**
|
|
3981
|
+
* Records what the player is actually reading for the active track.
|
|
3982
|
+
*
|
|
3983
|
+
* The reported source says whether the file is on disk; it does not say the
|
|
3984
|
+
* bytes reaching the decoder came from there. Media3 reads through
|
|
3985
|
+
* [LookaheadCache]'s CacheDataSource, so a rising cached-bytes total is the
|
|
3986
|
+
* proof that playback is being served from the cache rather than the origin.
|
|
3987
|
+
*/
|
|
3988
|
+
private fun logPlaybackSource(url: String?) {
|
|
3989
|
+
// Debug builds only, and never the query string: stream URLs carry auth
|
|
3990
|
+
// tokens there, and logcat is readable by every app on older devices.
|
|
3991
|
+
if (!BuildConfig.DEBUG) return
|
|
3992
|
+
val cachedBytes = lookaheadCache?.cachedBytesReadTotal?.get() ?: 0L
|
|
3993
|
+
Log.i(
|
|
3994
|
+
DIAG_TAG,
|
|
3995
|
+
"now-playing source=$currentTrackSource cachedBytesRead=$cachedBytes " +
|
|
3996
|
+
"url=${url?.let(PlaybackErrorMapping::stripQuery)}"
|
|
3997
|
+
)
|
|
3998
|
+
}
|
|
3999
|
+
|
|
3591
4000
|
override fun onCrossfadeCancel(engine: PlaybackEngine, revertedIndex: Int) {
|
|
3592
4001
|
// Fade-cancelled mid-flight (pause / stop / focus loss / queue
|
|
3593
4002
|
// mutation). JS already received `onTrackChange(incoming,
|
|
@@ -3601,10 +4010,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3601
4010
|
lastErrorKey = null
|
|
3602
4011
|
crossfadeFadeStartEmittedIdx = null
|
|
3603
4012
|
val track = tracks[revertedIndex]
|
|
3604
|
-
|
|
3605
|
-
currentTrackSource = classifyTrackSource(track.url) { url ->
|
|
3606
|
-
cache?.isFullyCached(url) == true
|
|
3607
|
-
}
|
|
4013
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3608
4014
|
pendingTrackChangeReason = null
|
|
3609
4015
|
trackChangeListeners.forEach {
|
|
3610
4016
|
it(track, revertedIndex.toDouble(), TrackChangeReason.CROSSFADE_CANCELLED, null)
|
|
@@ -3618,7 +4024,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3618
4024
|
val now = System.nanoTime()
|
|
3619
4025
|
gaplessGapPrevEndedAtNs = now
|
|
3620
4026
|
if (BuildConfig.DEBUG) {
|
|
3621
|
-
gaplessLogLastEndedAtNs = now
|
|
3622
4027
|
Log.d(
|
|
3623
4028
|
GAPLESS_LOG_TAG,
|
|
3624
4029
|
"item-ended trackIdx=$currentTrackIndex ts_ns=$now"
|
|
@@ -3626,7 +4031,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3626
4031
|
}
|
|
3627
4032
|
}
|
|
3628
4033
|
|
|
3629
|
-
|
|
3630
4034
|
/**
|
|
3631
4035
|
* Translate Media3 playback state + playWhenReady + suppression
|
|
3632
4036
|
* reason into our [PlayerState] enum and fire [stateChangeListeners]
|
|
@@ -3704,7 +4108,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3704
4108
|
if (index == currentTrackIndex) return
|
|
3705
4109
|
currentTrackIndex = index
|
|
3706
4110
|
val track = tracks[index]
|
|
4111
|
+
// No engine call happens on this path, so nothing downstream
|
|
4112
|
+
// reclassifies the source or recomputes the skip capability the
|
|
4113
|
+
// way handleMediaItemTransition does for a local advance.
|
|
4114
|
+
currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
|
|
3707
4115
|
trackChangeListeners.forEach { it(track, index.toDouble(), TrackChangeReason.AUTO_ADVANCE, null) }
|
|
4116
|
+
recomputeCapabilities()
|
|
3708
4117
|
}
|
|
3709
4118
|
|
|
3710
4119
|
/**
|
|
@@ -3724,6 +4133,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3724
4133
|
fireEndOfTrackSleepTimer()
|
|
3725
4134
|
queueEndListeners.forEach { it() }
|
|
3726
4135
|
emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
|
|
4136
|
+
// Park the engine. Reaching the end of the queue is not the same as being
|
|
4137
|
+
// paused — `playWhenReady` stays set through STATE_ENDED — so a queue
|
|
4138
|
+
// installed afterwards would start playing without anyone asking. Repeat
|
|
4139
|
+
// modes wrap without ever reaching STATE_ENDED, so they are unaffected.
|
|
4140
|
+
serviceBinder?.engine?.pause()
|
|
4141
|
+
// The intent to play ends with the queue: nothing that seats a track from
|
|
4142
|
+
// here — a skip, a seek, a fresh queue, an engine swap — starts it until
|
|
4143
|
+
// play() is called again.
|
|
4144
|
+
wantsToPlay = false
|
|
4145
|
+
// The pause's inline callback stamps USER_REQUEST; ENDED is already
|
|
4146
|
+
// reported, so that emit deduplicates and the stamp would label the next
|
|
4147
|
+
// terminal emit a user action. Nobody asked for this pause.
|
|
4148
|
+
pendingStateChangeReason = null
|
|
3727
4149
|
}
|
|
3728
4150
|
|
|
3729
4151
|
/**
|
|
@@ -3740,6 +4162,16 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3740
4162
|
* (user-skip), else fallback to [TrackChangeReason.SEEK]
|
|
3741
4163
|
* - REASON_PLAYLIST_CHANGED → [TrackChangeReason.QUEUE_REPLACED]
|
|
3742
4164
|
*/
|
|
4165
|
+
/**
|
|
4166
|
+
* Whether playback of [url] is served from the disk cache: the cache
|
|
4167
|
+
* holds the whole file AND is enabled. Disabled, every read goes to the
|
|
4168
|
+
* origin whatever the disk holds, so a complete copy does not make the
|
|
4169
|
+
* track `CACHED`. An item whose data source opened before a toggle keeps
|
|
4170
|
+
* that source, so its report can differ from the flag for that one item.
|
|
4171
|
+
*/
|
|
4172
|
+
private fun servedFromCache(url: String): Boolean =
|
|
4173
|
+
lookaheadConfig.enabled && lookaheadCache?.isFullyCached(url) == true
|
|
4174
|
+
|
|
3743
4175
|
@VisibleForTesting
|
|
3744
4176
|
internal fun handleMediaItemTransition(reason: Int) {
|
|
3745
4177
|
val p = player ?: return
|
|
@@ -3758,10 +4190,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3758
4190
|
// iOS reset in `handleCurrentItemDidChange`.
|
|
3759
4191
|
lastErrorKey = null
|
|
3760
4192
|
val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
|
|
3761
|
-
|
|
3762
|
-
currentTrackSource = classifyTrackSource(track?.url) { url ->
|
|
3763
|
-
cache?.isFullyCached(url) == true
|
|
3764
|
-
}
|
|
4193
|
+
currentTrackSource = classifyTrackSource(track?.url, ::servedFromCache)
|
|
3765
4194
|
|
|
3766
4195
|
// Crossfade post-swap echo: fade-start already fired
|
|
3767
4196
|
// `onCrossfadeBegin` which dispatched the JS-facing track-
|
|
@@ -3860,20 +4289,84 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3860
4289
|
* reschedule the proactive prefetcher after a
|
|
3861
4290
|
* queue/skip mutation. Reads `tracks` + `currentTrackIndex` (both
|
|
3862
4291
|
* @Volatile) and dispatches the next N remote tracks to
|
|
3863
|
-
* [LookaheadCacheWriter].
|
|
3864
|
-
* (configure not yet
|
|
4292
|
+
* [LookaheadCacheWriter]. Always refreshes cache protection; the prefetch
|
|
4293
|
+
* dispatch is skipped when the writer hasn't been built (configure not yet
|
|
4294
|
+
* called, or prefetch disabled).
|
|
3865
4295
|
*/
|
|
3866
4296
|
@MainThread
|
|
3867
4297
|
private fun rescheduleLookahead() {
|
|
4298
|
+
// Ahead of the writer guard on purpose: the cache is built at configure()
|
|
4299
|
+
// regardless of `lookaheadConfig.enabled`, so a consumer with prefetch off
|
|
4300
|
+
// still has a live cache that evicts, and it would otherwise run with an
|
|
4301
|
+
// empty protected set.
|
|
4302
|
+
refreshCacheProtection()
|
|
3868
4303
|
val writer = lookaheadCacheWriter ?: return
|
|
3869
4304
|
writer.reschedule(tracks, currentTrackIndex)
|
|
3870
4305
|
}
|
|
3871
4306
|
|
|
4307
|
+
/**
|
|
4308
|
+
* Tell the cache which entries eviction must not take: the playing track, the
|
|
4309
|
+
* tracks being prefetched ahead of it, and one behind for an immediate
|
|
4310
|
+
* skip-back.
|
|
4311
|
+
*
|
|
4312
|
+
* Bounded deliberately — protecting the whole remaining queue would leave
|
|
4313
|
+
* nothing evictable and turn each commit into a cache wipe.
|
|
4314
|
+
*/
|
|
4315
|
+
@MainThread
|
|
4316
|
+
private fun refreshCacheProtection() {
|
|
4317
|
+
val cache = lookaheadCache ?: return
|
|
4318
|
+
// Under LRU the set is discarded, and this runs on every queue mutation.
|
|
4319
|
+
if (!cache.honoursProtection) return
|
|
4320
|
+
val cur = currentTrackIndex
|
|
4321
|
+
if (cur < 0 || cur >= tracks.size) {
|
|
4322
|
+
cache.setProtectedUrls(emptySet())
|
|
4323
|
+
return
|
|
4324
|
+
}
|
|
4325
|
+
// The count arrives from JS as a Double, so Infinity and NaN are reachable.
|
|
4326
|
+
// Both survive `toInt()` as Int.MAX_VALUE / 0, and `cur + Int.MAX_VALUE`
|
|
4327
|
+
// wraps negative, which would make the subList bounds throw on main.
|
|
4328
|
+
// Bounded by MAX_PROTECTED_AHEAD as well as by the queue: a consumer count
|
|
4329
|
+
// at or above the queue length would protect every entry, leaving the
|
|
4330
|
+
// evictor nothing to reclaim and the cache growing past its budget with no
|
|
4331
|
+
// ceiling. The window that matters is the one around the playhead.
|
|
4332
|
+
val ahead = lookaheadConfig.lookaheadCount
|
|
4333
|
+
.takeIf { it.isFinite() }
|
|
4334
|
+
?.toInt()
|
|
4335
|
+
?.coerceIn(0, minOf(tracks.size, MAX_PROTECTED_AHEAD))
|
|
4336
|
+
?: 0
|
|
4337
|
+
val lower = (cur - 1).coerceAtLeast(0)
|
|
4338
|
+
val upper = (cur + ahead).coerceAtMost(tracks.size - 1)
|
|
4339
|
+
cache.setProtectedUrls(tracks.subList(lower, upper + 1).map { it.url }.toSet())
|
|
4340
|
+
}
|
|
4341
|
+
|
|
3872
4342
|
private fun syncCurrentTrackIndexFromPlayer(p: ExoPlayer) {
|
|
3873
4343
|
val playerIndex = p.currentMediaItemIndex
|
|
3874
4344
|
currentTrackIndex = if (playerIndex in tracks.indices) playerIndex else -1
|
|
3875
4345
|
}
|
|
3876
4346
|
|
|
4347
|
+
/** Scheduled auto-retry, held so teardown can cancel it. */
|
|
4348
|
+
private var pendingRetryRunnable: Runnable? = null
|
|
4349
|
+
|
|
4350
|
+
/**
|
|
4351
|
+
* Track index whose automatic attempt is scheduled but has not run yet. A
|
|
4352
|
+
* failure naming it is an echo of the one being retried, so it is not
|
|
4353
|
+
* reported while the attempt is still owed.
|
|
4354
|
+
*/
|
|
4355
|
+
private var pendingRetryIndex: Int = -1
|
|
4356
|
+
|
|
4357
|
+
/**
|
|
4358
|
+
* Drop a scheduled automatic attempt.
|
|
4359
|
+
*
|
|
4360
|
+
* Called wherever the queue changes, because the attempt is keyed by
|
|
4361
|
+
* position: after a mutation that index names a different track, and both
|
|
4362
|
+
* the attempt itself and the echo suppression would then be aimed at it.
|
|
4363
|
+
*/
|
|
4364
|
+
private fun cancelPendingRetry() {
|
|
4365
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4366
|
+
pendingRetryRunnable = null
|
|
4367
|
+
pendingRetryIndex = -1
|
|
4368
|
+
}
|
|
4369
|
+
|
|
3877
4370
|
/**
|
|
3878
4371
|
* Translate [PlaybackException] into a [PlaybackError] + emit on
|
|
3879
4372
|
* both [errorListeners] and as a terminal ERROR state. Mirrors iOS
|
|
@@ -3881,27 +4374,48 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3881
4374
|
* conditions surface as non-fatal so consumers can offer retry
|
|
3882
4375
|
* UX; codec / decoding errors surface as fatal.
|
|
3883
4376
|
*/
|
|
3884
|
-
private fun handlePlayerError(error: PlaybackException) {
|
|
4377
|
+
private fun handlePlayerError(error: PlaybackException, failedIndex: Int = -1) {
|
|
3885
4378
|
val mapped = PlaybackErrorMapping.classify(error)
|
|
3886
|
-
//
|
|
4379
|
+
// Attribute to the track that actually failed. Under crossfade the
|
|
4380
|
+
// standby leg can fail while prerolling an upcoming track, and the
|
|
4381
|
+
// leading leg plays on — keying any of the three uses below off
|
|
4382
|
+
// currentTrackIndex would dedup against the wrong track, spend the wrong
|
|
4383
|
+
// track's retry budget, and report the wrong url to the consumer.
|
|
4384
|
+
val idx = if (failedIndex in tracks.indices) failedIndex else currentTrackIndex
|
|
4385
|
+
// Dedup by (failing index, standardized code). Mirrors iOS
|
|
3887
4386
|
// `(queueItemId, code)` dedup. ExoPlayer can fire onPlayerError
|
|
3888
4387
|
// multiple times for a single failed item under retry storms.
|
|
3889
|
-
val key =
|
|
4388
|
+
val key = idx to mapped
|
|
4389
|
+
// An echo of a failure whose attempt has not run yet belongs to that
|
|
4390
|
+
// attempt, not to the consumer.
|
|
4391
|
+
if (idx >= 0 && idx == pendingRetryIndex) return
|
|
3890
4392
|
if (lastErrorKey == key) return
|
|
3891
4393
|
lastErrorKey = key
|
|
3892
4394
|
// Auto-retry path: transient errors with retries left
|
|
3893
4395
|
// schedule a delayed `prepare()`. Skip the JS error emit + state
|
|
3894
4396
|
// ERROR transition until retries exhausted; consumer sees onError
|
|
3895
4397
|
// only when the lib has given up. Mirrors iOS path.
|
|
3896
|
-
|
|
4398
|
+
// Only the track being played is retried. A standby leg's preroll failing
|
|
4399
|
+
// is the *next* track failing to load early, and the cancel that precedes
|
|
4400
|
+
// the report reverts `currentTrackIndex` to the leading track, so an
|
|
4401
|
+
// attempt scheduled for it could never run. Surfacing it instead is both
|
|
4402
|
+
// honest and harmless: when that track becomes current it is loaded fresh
|
|
4403
|
+
// and gets its own automatic attempt then.
|
|
3897
4404
|
if (idx >= 0 &&
|
|
4405
|
+
idx == currentTrackIndex &&
|
|
3898
4406
|
PlaybackErrorMapping.isTransient(mapped) &&
|
|
3899
4407
|
(retryAttemptsRemaining[idx] ?: 0) > 0) {
|
|
3900
4408
|
retryAttemptsRemaining[idx] = retryAttemptsRemaining[idx]!! - 1
|
|
3901
4409
|
// Clear dedup so the retry-then-fail (if any) fires fresh.
|
|
3902
4410
|
lastErrorKey = null
|
|
3903
4411
|
val backoff = effectiveRetryBackoffMs()
|
|
3904
|
-
|
|
4412
|
+
// Hold the runnable so teardown can drop it. A retry posted moments
|
|
4413
|
+
// before destroy() would otherwise fire against a released engine.
|
|
4414
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4415
|
+
val retry = Runnable { retryFailedItem(idx) }
|
|
4416
|
+
pendingRetryRunnable = retry
|
|
4417
|
+
pendingRetryIndex = idx
|
|
4418
|
+
mainHandler.postDelayed(retry, backoff.toLong())
|
|
3905
4419
|
return
|
|
3906
4420
|
}
|
|
3907
4421
|
// Surface the failing track's URL so consumers can correlate the
|
|
@@ -3915,16 +4429,22 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3915
4429
|
error, mapped, queueItemId = "", url = failedUrl,
|
|
3916
4430
|
)
|
|
3917
4431
|
errorListeners.forEach { it(err) }
|
|
3918
|
-
|
|
4432
|
+
// The state is the current track's. A standby leg's preroll failing is
|
|
4433
|
+
// reported above and leaves the leading leg playing; announcing ERROR for
|
|
4434
|
+
// it would put a terminal state under audio that is still running, and
|
|
4435
|
+
// the next engine callback would flip it straight back to PLAYING.
|
|
4436
|
+
if (idx == currentTrackIndex) {
|
|
4437
|
+
emitState(PlayerState.ERROR, StateChangeReason.ERROR)
|
|
4438
|
+
}
|
|
3919
4439
|
}
|
|
3920
4440
|
|
|
3921
4441
|
// --- Auto-retry + stuck-recovery
|
|
3922
4442
|
|
|
3923
4443
|
/** Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
|
|
3924
|
-
* defaults to
|
|
4444
|
+
* defaults to 1, clamps to [0, 5]. */
|
|
3925
4445
|
@VisibleForTesting
|
|
3926
4446
|
internal fun effectiveAutoRetries(): Int =
|
|
3927
|
-
(config.autoRetries?.toInt() ?:
|
|
4447
|
+
(config.autoRetries?.toInt() ?: 1).coerceIn(0, 5)
|
|
3928
4448
|
|
|
3929
4449
|
/** Lib-clamped retry backoff in milliseconds. Defaults to 500,
|
|
3930
4450
|
* clamps to [200, 5000]. Floor at 200ms prevents retry storms
|
|
@@ -3935,28 +4455,41 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3935
4455
|
|
|
3936
4456
|
/** Try to recover from a transient failure by re-preparing the
|
|
3937
4457
|
* ExoPlayer at the current position. Called from
|
|
3938
|
-
* [handlePlayerError] (auto-retry)
|
|
3939
|
-
*
|
|
3940
|
-
*
|
|
4458
|
+
* [handlePlayerError] (auto-retry) and from [retry] (the
|
|
4459
|
+
* consumer-facing one-shot). Mirrors iOS `retryFailedItem`, which
|
|
4460
|
+
* rebuilds the AVQueuePlayer via `fullRebuildPlayerQueue` and seeks
|
|
4461
|
+
* back to the captured position.
|
|
3941
4462
|
*
|
|
3942
|
-
* Implementation:
|
|
3943
|
-
*
|
|
3944
|
-
* the existing wedged DataSource
|
|
3945
|
-
* failure and may not actually recover.
|
|
3946
|
-
*
|
|
3947
|
-
* the MediaSource.Factory (carries the current `httpFactory`
|
|
3948
|
-
* + cache wrapper).
|
|
3949
|
-
* current playback position; iOS rebuild resets to 0 (mid-play
|
|
3950
|
-
* failure loses position regardless). For initial-load failures
|
|
3951
|
-
* the position is 0 anyway, so the position-preservation here
|
|
3952
|
-
* is the only intentional iOS/Android divergence. */
|
|
4463
|
+
* Implementation: remove + re-add the failed item at its index,
|
|
4464
|
+
* `seekTo` the captured position, then `prepare()` — rather than
|
|
4465
|
+
* bare `prepare()`, which re-uses the existing wedged DataSource
|
|
4466
|
+
* on a "Cannot Open"-class failure and may not actually recover.
|
|
4467
|
+
* Re-adding the item forces Media3 to construct a fresh DataSource
|
|
4468
|
+
* from the MediaSource.Factory (carries the current `httpFactory`
|
|
4469
|
+
* + cache wrapper). */
|
|
3953
4470
|
internal fun retryFailedItem(atIndex: Int) {
|
|
3954
|
-
|
|
3955
|
-
//
|
|
3956
|
-
//
|
|
3957
|
-
// the
|
|
3958
|
-
|
|
4471
|
+
// The attempt has arrived, so whatever fails next is a new failure rather
|
|
4472
|
+
// than an echo of the one being retried. A scheduled attempt for the same
|
|
4473
|
+
// index is withdrawn with it: left posted, it would fire after the backoff
|
|
4474
|
+
// and rebuild the track a second time behind a user's own retry.
|
|
4475
|
+
if (pendingRetryIndex == atIndex) {
|
|
4476
|
+
pendingRetryRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
4477
|
+
pendingRetryIndex = -1
|
|
4478
|
+
pendingRetryRunnable = null
|
|
4479
|
+
}
|
|
4480
|
+
// The failed item must still be the current one; a skip or a queue mutation
|
|
4481
|
+
// since the failure makes the retry moot. Checked before the fade is
|
|
4482
|
+
// settled, because settling is an audible cut and a retry that is about to
|
|
4483
|
+
// return has no business making one.
|
|
3959
4484
|
if (atIndex != currentTrackIndex) return
|
|
4485
|
+
// Settle a fade before reading the player. `player` is the leading leg,
|
|
4486
|
+
// which mid-fade holds the OUTGOING track while `currentTrackIndex` has
|
|
4487
|
+
// already flipped to the incoming one — so every read below would describe
|
|
4488
|
+
// a different track than the index check just approved, and a retry would
|
|
4489
|
+
// rebuild and restart the track the user is leaving. Promoting first makes
|
|
4490
|
+
// the leading leg the track being heard, which is the one a retry is for.
|
|
4491
|
+
serviceBinder?.engine?.settlePendingFadeByPromoting()
|
|
4492
|
+
val p = player ?: return
|
|
3960
4493
|
val current = p.currentMediaItem
|
|
3961
4494
|
if (current != null) {
|
|
3962
4495
|
val idx = p.currentMediaItemIndex
|
|
@@ -3978,10 +4511,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3978
4511
|
} else {
|
|
3979
4512
|
p.prepare()
|
|
3980
4513
|
}
|
|
3981
|
-
// Resume
|
|
3982
|
-
// (
|
|
3983
|
-
//
|
|
3984
|
-
if (!p.isPlaying) serviceBinder?.engine?.play()
|
|
4514
|
+
// Resume only if playback is wanted NOW, not at failure time: a manual
|
|
4515
|
+
// retry() carries no failure-time flag, and a pause during the retry
|
|
4516
|
+
// backoff must win over the state the failure was scheduled under.
|
|
4517
|
+
if (wantsToPlay && !p.isPlaying) serviceBinder?.engine?.play()
|
|
3985
4518
|
}
|
|
3986
4519
|
|
|
3987
4520
|
/**
|
|
@@ -4244,13 +4777,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4244
4777
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
4245
4778
|
body()
|
|
4246
4779
|
} else {
|
|
4247
|
-
val
|
|
4780
|
+
val done = CountDownLatch(1)
|
|
4248
4781
|
var err: Throwable? = null
|
|
4249
4782
|
mainHandler.post {
|
|
4250
4783
|
try { body() } catch (t: Throwable) { err = t }
|
|
4251
|
-
|
|
4784
|
+
done.countDown()
|
|
4252
4785
|
}
|
|
4253
|
-
|
|
4786
|
+
done.await()
|
|
4254
4787
|
err?.let { throw it }
|
|
4255
4788
|
}
|
|
4256
4789
|
}
|
|
@@ -4275,18 +4808,45 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4275
4808
|
}
|
|
4276
4809
|
|
|
4277
4810
|
companion object {
|
|
4811
|
+
/** Prefs holding the eviction policy the on-disk cache was built with. */
|
|
4812
|
+
private const val CACHE_PREFS_NAME = "rnqp_lookahead_cache"
|
|
4813
|
+
private const val KEY_EVICTION_POLICY = "eviction_policy"
|
|
4814
|
+
|
|
4815
|
+
/** Marks a cache directory renamed aside and awaiting deletion. */
|
|
4816
|
+
private const val DELETING_DIR_SUFFIX = ".deleting-"
|
|
4817
|
+
|
|
4818
|
+
/**
|
|
4819
|
+
* Renamed cache directories this process is currently deleting.
|
|
4820
|
+
*
|
|
4821
|
+
* The sweep collects anything wearing the deleting- prefix, which is
|
|
4822
|
+
* exactly the name a discard just produced — so without this it starts a
|
|
4823
|
+
* second delete of a tree already being deleted, with a second database
|
|
4824
|
+
* provider dropping the same index tables underneath the first.
|
|
4825
|
+
*
|
|
4826
|
+
* Process-wide because the cache is: a second [TrackPlayer] configured in
|
|
4827
|
+
* the same process sweeps the same directory. Held only while a delete is
|
|
4828
|
+
* in flight, so a directory abandoned by a previous process — the case the
|
|
4829
|
+
* sweep exists for — is never claimed and is always collected.
|
|
4830
|
+
*/
|
|
4831
|
+
private val doomedCacheDirectories = java.util.Collections.synchronizedSet(
|
|
4832
|
+
mutableSetOf<String>())
|
|
4833
|
+
|
|
4834
|
+
private fun claimDoomedDirectory(dir: java.io.File) {
|
|
4835
|
+
doomedCacheDirectories.add(dir.absolutePath)
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
private fun releaseDoomedDirectory(dir: java.io.File) {
|
|
4839
|
+
doomedCacheDirectories.remove(dir.absolutePath)
|
|
4840
|
+
}
|
|
4841
|
+
|
|
4842
|
+
private fun isDoomedDirectoryClaimed(dir: java.io.File): Boolean =
|
|
4843
|
+
doomedCacheDirectories.contains(dir.absolutePath)
|
|
4844
|
+
|
|
4278
4845
|
/** Progress tick cadence — matches iOS 2 Hz. */
|
|
4279
4846
|
private const val PROGRESS_TICK_INTERVAL_MS = 500L
|
|
4280
4847
|
/** Sleep-timer countdown/fade cadence — 500ms gives a smooth 10s fade. */
|
|
4281
4848
|
private const val SLEEP_TIMER_TICK_INTERVAL_MS = 500L
|
|
4282
4849
|
|
|
4283
|
-
/**
|
|
4284
|
-
* Disposer returned from `on*Request` registrations when the
|
|
4285
|
-
* `PlaybackService` isn't bound yet. Calling it is a no-op —
|
|
4286
|
-
* the registration never landed, so there's nothing to clear.
|
|
4287
|
-
*/
|
|
4288
|
-
private val NO_OP_DISPOSER: () -> Unit = {}
|
|
4289
|
-
|
|
4290
4850
|
/** Tolerance for "bufferedPosition has reached duration" — a CBR-estimated
|
|
4291
4851
|
* duration or rounding can leave a sub-second gap when fully downloaded. */
|
|
4292
4852
|
private const val FULLY_BUFFERED_EPSILON_MS = 1000L
|
|
@@ -4296,6 +4856,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4296
4856
|
* reference one named constant. */
|
|
4297
4857
|
private const val MS_PER_SECOND = 1000.0
|
|
4298
4858
|
|
|
4859
|
+
/**
|
|
4860
|
+
* Upper bound on how far ahead of the playhead cache eviction is blocked.
|
|
4861
|
+
*
|
|
4862
|
+
* Independent of the consumer's lookahead count on purpose: protecting the
|
|
4863
|
+
* whole queue leaves the evictor nothing to reclaim, and the cache then
|
|
4864
|
+
* grows past its budget with no ceiling.
|
|
4865
|
+
*/
|
|
4866
|
+
private const val MAX_PROTECTED_AHEAD = 10
|
|
4867
|
+
|
|
4299
4868
|
/**
|
|
4300
4869
|
* Tag for the verbose gapless-transition logs (item-ended /
|
|
4301
4870
|
* item-changed / playing). All emit sites are guarded by
|
|
@@ -4304,6 +4873,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4304
4873
|
*/
|
|
4305
4874
|
private const val GAPLESS_LOG_TAG = "RNQP-GAPLESS"
|
|
4306
4875
|
|
|
4876
|
+
/** Playback-source diagnostics: what the player actually reads from. */
|
|
4877
|
+
private const val DIAG_TAG = "RNQP-DIAG"
|
|
4878
|
+
|
|
4307
4879
|
/** Logcat tag for player-level lifecycle warnings. Part of the
|
|
4308
4880
|
* `QueuePlayer.<area>` hierarchy used across the lib's Android
|
|
4309
4881
|
* sources. */
|
|
@@ -4323,6 +4895,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
4323
4895
|
var current: TrackPlayer? = null
|
|
4324
4896
|
private set
|
|
4325
4897
|
|
|
4898
|
+
init {
|
|
4899
|
+
// One process-wide listener on the cast route, forwarding to whichever
|
|
4900
|
+
// instance is configured. Push-PCM sessions (AirPlay 1/2) force-degrade
|
|
4901
|
+
// the active engine from Crossfade to Gapless because the wire protocol
|
|
4902
|
+
// cannot mix two ExoPlayers into one sink; clearing the session
|
|
4903
|
+
// re-applies the user's requested mode (so crossfade re-instates).
|
|
4904
|
+
// Registered once here rather than per instance: `CastSinkRouter` is a
|
|
4905
|
+
// process-wide object, and a per-instance listener kept every instance
|
|
4906
|
+
// ever constructed reachable and ran the swap on all of them.
|
|
4907
|
+
com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
|
|
4908
|
+
val player = current ?: return@addRouteChangeListener
|
|
4909
|
+
// Re-apply against the user's requested mode on every flip.
|
|
4910
|
+
// `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
|
|
4911
|
+
// for the degrade decision, so the swap settles to the right
|
|
4912
|
+
// engine for the new route state.
|
|
4913
|
+
val mode = player.playbackModeState.active
|
|
4914
|
+
player.mainHandler.post { player.applyEngineSwapForMode(mode) }
|
|
4915
|
+
}
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4326
4918
|
/**
|
|
4327
4919
|
* Classify the active-track playback source. `null` when the URL
|
|
4328
4920
|
* is null or its scheme is neither `file://` nor `http(s)://`
|