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
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension TrackPlayer {
|
|
5
|
+
// MARK: - Lifecycle
|
|
6
|
+
|
|
7
|
+
func configure(config: PlayerConfig) throws -> Promise<Void> {
|
|
8
|
+
// `[self]` explicit: this body holds `self` strongly for its run, and the
|
|
9
|
+
// long-lived callbacks it installs below capture `[weak self]`; the
|
|
10
|
+
// explicit outer capture is what tells the compiler the mix is intended.
|
|
11
|
+
return enqueue { [self] in
|
|
12
|
+
// Universal auto-destroy contract: every `configure()` call
|
|
13
|
+
// tears down any prior engine, audio session, observers, and
|
|
14
|
+
// pending pipeline state before reinitialising. On the first
|
|
15
|
+
// configure of a session the gate inside `internalDestroy`
|
|
16
|
+
// short-circuits when `self.engine == nil`, so only the timer +
|
|
17
|
+
// sleep-timer teardown runs; on subsequent calls it is a real
|
|
18
|
+
// teardown. Effect: configure is always a clean-slate operation;
|
|
19
|
+
// consumers don't need to remember to call `destroy()` before
|
|
20
|
+
// re-configuring.
|
|
21
|
+
// Capture the prior eviction policy before overwriting config — a change
|
|
22
|
+
// clears the rebuilt cache below so it starts fresh under the new policy
|
|
23
|
+
// (infrequent; a cache's eviction ordering is construction-fixed).
|
|
24
|
+
let previousEvictionPolicy = self.config.lookaheadCacheEvictionPolicy ?? .lru
|
|
25
|
+
self.internalDestroy()
|
|
26
|
+
self.config = config
|
|
27
|
+
let evictionPolicyChanged =
|
|
28
|
+
previousEvictionPolicy != (config.lookaheadCacheEvictionPolicy ?? .lru)
|
|
29
|
+
// Seed the progress-emission throttle from config and start observing
|
|
30
|
+
// app background/foreground. `internalDestroy()` above removed any
|
|
31
|
+
// prior observers, so this re-registers cleanly on every configure.
|
|
32
|
+
self.applyProgressEmissionIntervals()
|
|
33
|
+
self.installAppLifecycleObservers()
|
|
34
|
+
// Pin-at-configure visualization-disabled flag. Mirrored to
|
|
35
|
+
// the AudioTapProvider singleton so `Visualizer.subscribe`
|
|
36
|
+
// and the Visualizer Consumer's `wantsTap` predicate can
|
|
37
|
+
// read it live. Default `true` preserves existing behaviour.
|
|
38
|
+
AudioTapProvider.shared.setVisualizationEnabled(
|
|
39
|
+
config.visualizationEnabled ?? true
|
|
40
|
+
)
|
|
41
|
+
// Taken from the first call and never re-read: the Siri entitlement this
|
|
42
|
+
// stands for is fixed when the app is signed, so a later value could not
|
|
43
|
+
// mean anything.
|
|
44
|
+
self.pinVoiceVocabularyDonation(
|
|
45
|
+
config.voiceVocabularyDonationEnabled ?? false
|
|
46
|
+
)
|
|
47
|
+
// The queue mirror is only maintained while a car is attached, so
|
|
48
|
+
// rebuild it the moment one attaches.
|
|
49
|
+
CarPlayCoordinator.shared.onCarAttached = { [weak self] in
|
|
50
|
+
guard let self else { return }
|
|
51
|
+
// The car scene attaches on main, and the mirror is built from the
|
|
52
|
+
// track list — so it is built on the queue that owns it. Fires from
|
|
53
|
+
// `setCarConnected(true)`, so the car is attached by construction.
|
|
54
|
+
self.playerQueue.async {
|
|
55
|
+
self.syncCarPlayQueueMirror(
|
|
56
|
+
self.buildCarPlayQueueSnapshot(), carAttached: true)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let engine = GaplessEngine()
|
|
60
|
+
self.engine = engine
|
|
61
|
+
// configure() always rebuilds the default GaplessEngine, so the mode
|
|
62
|
+
// state must reset to match. Otherwise getPlaybackMode() keeps
|
|
63
|
+
// reporting a stale crossfade selection after a clean-slate
|
|
64
|
+
// reconfigure while the engine is actually gapless — and a subsequent
|
|
65
|
+
// setPlaybackMode(.crossfade) would no-op against that stale state and
|
|
66
|
+
// never rebuild the CrossfadeEngine.
|
|
67
|
+
self.playbackModeState = PlaybackModeStateMachine.initial
|
|
68
|
+
let player = engine.player
|
|
69
|
+
self.player = player
|
|
70
|
+
self.gaplessFlipArmed = true
|
|
71
|
+
self.installGaplessObservers(on: player)
|
|
72
|
+
self.installEventObservers(on: player)
|
|
73
|
+
// Re-apply persisted config to the fresh engine. These survive a
|
|
74
|
+
// destroy→configure cycle and may be set before the first
|
|
75
|
+
// configure, so the first engine must pick them up — matching the
|
|
76
|
+
// engine-swap path. Speed is seeded (not set) so it doesn't start
|
|
77
|
+
// the paused player via AVPlayer.rate; play() applies it on the
|
|
78
|
+
// first resume.
|
|
79
|
+
engine.setRepeatMode(self.repeatModeState)
|
|
80
|
+
engine.setVolume(self.volumeState)
|
|
81
|
+
engine.seedPlaybackSpeed(self.playbackSpeedState)
|
|
82
|
+
engine.setPitchCorrectionMode(self.pitchCorrectionModeState)
|
|
83
|
+
self.wireAudioSession()
|
|
84
|
+
// Start observing receiver-driven cast session state; idempotent
|
|
85
|
+
// on re-configure (no-op if already started).
|
|
86
|
+
self.castEventBridge.start()
|
|
87
|
+
// Activate the lockscreen mirror for cast sessions. Subscribes
|
|
88
|
+
// through `PlaybackStateRouter.shared` — fires on every
|
|
89
|
+
// session-active transition.
|
|
90
|
+
CastNowPlayingController.shared.start()
|
|
91
|
+
// The provider assigns `AVPlayerItem.audioMix` and walks the player's
|
|
92
|
+
// items to do it, so its engine-facing work belongs on the queue that
|
|
93
|
+
// serialises player access. Set before wiring, so the wire itself
|
|
94
|
+
// lands there. The provider is a process-wide singleton, hence the
|
|
95
|
+
// weak capture — teardown puts the target back on main.
|
|
96
|
+
AudioTapProvider.shared.setMixDispatchTarget(
|
|
97
|
+
self.playerQueue,
|
|
98
|
+
isCurrent: { [weak self] in self?.isOnPlayerQueue ?? false })
|
|
99
|
+
// Wire the shared audio-tap provider onto the engine. The
|
|
100
|
+
// provider attaches an MTAudioProcessingTap-backed
|
|
101
|
+
// AVAudioMix to each queued AVPlayerItem when any
|
|
102
|
+
// registered consumer (EQ, visualizer) wants the tap.
|
|
103
|
+
AudioTapProvider.shared.wireToPlaybackEngine(engine)
|
|
104
|
+
// Notify subscribers when a tap-side RG extract lands so any
|
|
105
|
+
// `onNowPlayingFormatChange` listener picks up the new RG
|
|
106
|
+
// values immediately. `getNowPlayingFormat()` reads RG live
|
|
107
|
+
// from `AudioTapProvider`, so the synchronous read path
|
|
108
|
+
// doesn't depend on this callback — it's purely for change-
|
|
109
|
+
// notification subscribers.
|
|
110
|
+
AudioTapProvider.shared.onItemReplayGainDataPopulated = { [weak self] item in
|
|
111
|
+
guard let self else { return }
|
|
112
|
+
self.emitCurrentNowPlayingFormat(for: item)
|
|
113
|
+
}
|
|
114
|
+
// Category only. Claiming the session is deferred to the first play, so
|
|
115
|
+
// merely loading the module does not stop whatever else the user has
|
|
116
|
+
// playing — a consumer app calls configure() at launch, long before
|
|
117
|
+
// anyone presses play. The category still has to be set here: route
|
|
118
|
+
// sharing and the interruption observers below depend on it.
|
|
119
|
+
self.audioSession.configureCategory(
|
|
120
|
+
mode: AudioCategoryMapping.mode(for: config.audioContentType),
|
|
121
|
+
options: AudioCategoryMapping.options(
|
|
122
|
+
for: config.audioCategoryOptions,
|
|
123
|
+
contentType: config.audioContentType
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
self.audioSession.installObservers()
|
|
127
|
+
let networkMonitor = PlaybackNetworkMonitor(queue: self.playerQueue)
|
|
128
|
+
networkMonitor.onNetworkRestored = { [weak self] in self?.handleNetworkRestored() }
|
|
129
|
+
networkMonitor.start()
|
|
130
|
+
self.networkMonitor = networkMonitor
|
|
131
|
+
self.startNowPlayingPeriodicTimer()
|
|
132
|
+
self.wireRemoteCommands()
|
|
133
|
+
self.remoteCommands.install()
|
|
134
|
+
self.remoteCommands.setSkipCapability(
|
|
135
|
+
canSkipNext: self.cachedSkipCapability.canSkipNext,
|
|
136
|
+
canSkipPrevious: self.cachedSkipCapability.canSkipPrevious
|
|
137
|
+
)
|
|
138
|
+
// Route CarPlay Up Next row taps through the same steps the
|
|
139
|
+
// `skipToIndex` Hybrid runs — engine guard, index validation,
|
|
140
|
+
// and cast routing (a tap during an active cast session jumps
|
|
141
|
+
// the RECEIVER; the local engine stays paused, so a local index
|
|
142
|
+
// write would desync from it) — then the local index write.
|
|
143
|
+
// Invoked on main by the scene delegate's Up Next row handler; the
|
|
144
|
+
// index validation and the write both touch player state, so the body
|
|
145
|
+
// runs on the queue that owns it.
|
|
146
|
+
CarPlayCoordinator.shared.skipToIndexHandler = { [weak self] index in
|
|
147
|
+
guard let self = self else { return }
|
|
148
|
+
self.playerQueue.async {
|
|
149
|
+
guard self.engine != nil else { return }
|
|
150
|
+
guard let target = InputGuards.validQueueIndex(
|
|
151
|
+
Double(index), count: self.tracks.count
|
|
152
|
+
) else { return }
|
|
153
|
+
if CastTransportRouter.routeSkipToIndex(index: target) { return }
|
|
154
|
+
self.applyNewCurrentIndex(newIndex: target, reason: .userSkipToIndex)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Refresh on every configure() so a header / UA update lands
|
|
158
|
+
// on the next artwork resolve. Live config — read fresh on
|
|
159
|
+
// each track-change resolve.
|
|
160
|
+
self.artworkResolver.configHeaders = config.httpHeaders
|
|
161
|
+
self.artworkResolver.configUserAgent = config.userAgent
|
|
162
|
+
// Swap the cover-art placeholder to the consumer-supplied image
|
|
163
|
+
// (or back to the built-in when unset). Every placeholder site
|
|
164
|
+
// reads `PlaceholderArtwork.image`, so this is the only hook needed.
|
|
165
|
+
PlaceholderArtwork.setCustom(uri: config.placeholderArtworkUri)
|
|
166
|
+
// Same live config for the cast lockscreen's own artwork
|
|
167
|
+
// resolver so authenticated covers resolve during cast.
|
|
168
|
+
CastNowPlayingController.shared.configureArtwork(
|
|
169
|
+
headers: config.httpHeaders, userAgent: config.userAgent
|
|
170
|
+
)
|
|
171
|
+
// Build the lookahead cache + prefetcher honouring the live
|
|
172
|
+
// `lookaheadConfig`. When disabled, neither is built.
|
|
173
|
+
// `internalDestroy` above already nilled out any prior cache
|
|
174
|
+
// + prefetcher, so this is always a fresh construction.
|
|
175
|
+
if self.lookaheadConfig.enabled {
|
|
176
|
+
let cache = LookaheadCache(
|
|
177
|
+
maxSizeBytes: LookaheadCache.maxBytes(
|
|
178
|
+
forMb: self.configuredCacheMaxSizeMb()
|
|
179
|
+
),
|
|
180
|
+
evictionPolicy: self.configuredEvictionPolicy(),
|
|
181
|
+
allowsCellularAccess: self.lookaheadConfig.allowsCellularAccess ?? true
|
|
182
|
+
)
|
|
183
|
+
// A policy change wipes the retained files so the cache refills
|
|
184
|
+
// under the new eviction order.
|
|
185
|
+
if evictionPolicyChanged {
|
|
186
|
+
cache.clear()
|
|
187
|
+
}
|
|
188
|
+
self.lookaheadCache = cache
|
|
189
|
+
let prefetcher = LookaheadCachePrefetcher(
|
|
190
|
+
cache: cache,
|
|
191
|
+
confinedTo: self.playerQueue,
|
|
192
|
+
configHeaders: config.httpHeaders,
|
|
193
|
+
configUserAgent: config.userAgent
|
|
194
|
+
)
|
|
195
|
+
prefetcher.defaultLookaheadCount =
|
|
196
|
+
InputGuards.validLookaheadCount(self.lookaheadConfig.lookaheadCount)
|
|
197
|
+
prefetcher.onTrackProcessed = { [weak self] in self?.emitCacheStatus() }
|
|
198
|
+
self.lookaheadCachePrefetcher = prefetcher
|
|
199
|
+
self.startReadThroughServer(for: cache)
|
|
200
|
+
} else if evictionPolicyChanged {
|
|
201
|
+
// Cache disabled at configure — no instance to clear(); purge the
|
|
202
|
+
// on-disk files directly so a policy change still starts fresh
|
|
203
|
+
// (parity with Android's unconditional clear).
|
|
204
|
+
LookaheadCache.purgeDefaultDirectory()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Install AudioSession interruption + route-change callbacks on
|
|
210
|
+
/// top of the transport. Centralised here so both callback types
|
|
211
|
+
/// share the same emit-state-then-call-native-method pattern.
|
|
212
|
+
private func wireAudioSession() {
|
|
213
|
+
self.audioSession.onInterruption = { [weak self] kind in
|
|
214
|
+
guard let self else { return }
|
|
215
|
+
// Delivered on main; everything below reads or writes player state.
|
|
216
|
+
self.playerQueue.async {
|
|
217
|
+
switch kind {
|
|
218
|
+
case .began:
|
|
219
|
+
// System wants us paused (phone call, Siri, alarm). Pause
|
|
220
|
+
// + stash `.interruption` for the resulting `timeControl
|
|
221
|
+
// Status` KVO to pick up on the terminal `.paused` emit.
|
|
222
|
+
//
|
|
223
|
+
// Through the engine, mirroring `.endedShouldResume`: CrossfadeEngine
|
|
224
|
+
// has no `self.player`, and its `pause()` also cancels an in-flight
|
|
225
|
+
// fade and its arming observer. Pausing the raw player would leave
|
|
226
|
+
// the fade's wall-clock completion task live to swap legs mid-call.
|
|
227
|
+
self.pendingStateChangeReason = .interruption
|
|
228
|
+
self.isInterrupted = true
|
|
229
|
+
// The system holds the session now; the resume below reactivates
|
|
230
|
+
// it only if this is cleared.
|
|
231
|
+
self.audioSession.noteInterrupted()
|
|
232
|
+
self.engine?.pause()
|
|
233
|
+
case .endedShouldResume:
|
|
234
|
+
// System says it's safe to resume. Resume through the engine so
|
|
235
|
+
// the user's playback speed is restored (a raw AVPlayer.play()
|
|
236
|
+
// resets rate to 1.0) and crossfade — whose legs the OS paused on
|
|
237
|
+
// `.began` but which has no `self.player` to act on — resumes too.
|
|
238
|
+
// play() transitions via `.buffering` → `.playing`; buffering
|
|
239
|
+
// passes through as `.system` so the reason sticks until
|
|
240
|
+
// `.playing` lands.
|
|
241
|
+
self.pendingStateChangeReason = .interruption
|
|
242
|
+
self.isInterrupted = false
|
|
243
|
+
// Some interruptions deactivate our session — a call that took the
|
|
244
|
+
// route, notably. Resuming into a session we never reactivated plays
|
|
245
|
+
// nothing, so reactivate before the transport call.
|
|
246
|
+
self.audioSession.activate(
|
|
247
|
+
mode: AudioCategoryMapping.mode(for: self.config.audioContentType),
|
|
248
|
+
options: AudioCategoryMapping.options(
|
|
249
|
+
for: self.config.audioCategoryOptions,
|
|
250
|
+
contentType: self.config.audioContentType
|
|
251
|
+
))
|
|
252
|
+
self.beginPlayback()
|
|
253
|
+
case .endedShouldNotResume:
|
|
254
|
+
// Interruption ended but user resolved it in a way that
|
|
255
|
+
// shouldn't restart music. The player is already paused
|
|
256
|
+
// (OS pre-paused on `.began`, we stayed paused), so no
|
|
257
|
+
// transport call and no state transition to report.
|
|
258
|
+
// Apple's docs confirm clients should not resume — no
|
|
259
|
+
// client action needed; a dedicated `onInterruptionEnd`
|
|
260
|
+
// event could land later if consumers need to clear UI
|
|
261
|
+
// annotations.
|
|
262
|
+
//
|
|
263
|
+
// Intent is cleared too: the system has told us the user resolved
|
|
264
|
+
// this in a way that should not restart music, so a later window
|
|
265
|
+
// refill or retry must not treat the pre-interruption intent as
|
|
266
|
+
// still standing.
|
|
267
|
+
self.isInterrupted = false
|
|
268
|
+
self.wantsToPlay = false
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// An interruption is a normal lifecycle signal (call / Siri / a route
|
|
273
|
+
// handoff), NOT a playback error — surfacing it on the `onError` stream
|
|
274
|
+
// makes consumers render an error banner for a routine pause. The pause
|
|
275
|
+
// is already reported via the `.paused` state stamped with
|
|
276
|
+
// `reason = .interruption`, which is the correct, non-error signal.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
self.audioSession.onRouteChange = { [weak self] kind in
|
|
281
|
+
guard let self else { return }
|
|
282
|
+
// Delivered on main; everything below reads or writes player state.
|
|
283
|
+
self.playerQueue.async {
|
|
284
|
+
switch kind {
|
|
285
|
+
case .oldDeviceUnavailable:
|
|
286
|
+
// Headphones unplugged, BT disconnected — never blast
|
|
287
|
+
// music over the speaker per Apple HIG. Pause through the engine:
|
|
288
|
+
// `self.player` is nil under CrossfadeEngine, so reading it here
|
|
289
|
+
// would let the route change pass unhandled on that engine and do
|
|
290
|
+
// the one thing this case exists to prevent.
|
|
291
|
+
self.pendingStateChangeReason = .routeChange
|
|
292
|
+
// Drop the transport intent too. This pause is final — the HIG says a
|
|
293
|
+
// vanished route must not resume on its own — and the resume paths
|
|
294
|
+
// (drained refill, retry) read the intent, so leaving it set would let
|
|
295
|
+
// one of them start audio out of the speaker afterwards. An
|
|
296
|
+
// interruption is deliberately different: it keeps the intent, because
|
|
297
|
+
// `.endedShouldResume` is meant to resume.
|
|
298
|
+
self.wantsToPlay = false
|
|
299
|
+
self.engine?.pause()
|
|
300
|
+
case .newDeviceAvailable:
|
|
301
|
+
// New route arrived (BT connected, headphones plugged in).
|
|
302
|
+
// No automatic transport action; state is unchanged, so
|
|
303
|
+
// no emission either. Consumers wanting to observe new
|
|
304
|
+
// devices can subscribe to AVAudioSession notifications
|
|
305
|
+
// directly if needed.
|
|
306
|
+
break
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Audio-session lifecycle failures (`setCategory` /
|
|
312
|
+
// `setActive(true)` / `setActive(false)`) surface as non-fatal
|
|
313
|
+
// PlaybackErrors with stable codes (see `AudioSession.onError`
|
|
314
|
+
// doc). Deliberately do NOT call `emitState(.error, ...)` —
|
|
315
|
+
// these are session-level failures, not player-level; the
|
|
316
|
+
// player itself isn't in an error state and may still recover
|
|
317
|
+
// (e.g. another app released the session).
|
|
318
|
+
self.audioSession.onError = { [weak self] sessionError in
|
|
319
|
+
guard let self else { return }
|
|
320
|
+
// Audio-session errors don't have a meaningful AVPlayer-level
|
|
321
|
+
// standardized code (they're session-scope, not playback-
|
|
322
|
+
// scope). Surface as `.unknown` with the SCREAMING_SNAKE_CASE
|
|
323
|
+
// session code in `nativeDomain` so consumers can branch on
|
|
324
|
+
// `nativeDomain.startsWith("AUDIO_SESSION_")` if they need
|
|
325
|
+
// session-specific UX.
|
|
326
|
+
// Apply the same `stripQuery` PII safeguard as the rest of
|
|
327
|
+
// the error fire sites — audio-session messages today don't
|
|
328
|
+
// carry URLs, but defense in depth.
|
|
329
|
+
let safeMessage = PlaybackErrorMapping.stripQuery(sessionError.message)
|
|
330
|
+
// Audio-session errors are session-scope; no specific queue
|
|
331
|
+
// item to correlate, so queueItemId + url are empty.
|
|
332
|
+
let err = PlaybackError(
|
|
333
|
+
code: .unknown,
|
|
334
|
+
message: safeMessage,
|
|
335
|
+
fatal: false,
|
|
336
|
+
nativeCode: Double(sessionError.nativeCode),
|
|
337
|
+
nativeDomain: sessionError.nativeDomain,
|
|
338
|
+
nativeMessage: safeMessage,
|
|
339
|
+
queueItemId: "",
|
|
340
|
+
url: ""
|
|
341
|
+
)
|
|
342
|
+
self.errorListeners.forEach { $0(err) }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/// Map `MPRemoteCommandCenter` events to the public transport
|
|
347
|
+
/// methods. The closure stamps `pendingStateChangeReason = .system`
|
|
348
|
+
/// before invoking the transport call so the conditional
|
|
349
|
+
/// `pendingStateChangeReason ?? .user` write inside each transport
|
|
350
|
+
/// method short-circuits and the resulting state-change emit
|
|
351
|
+
/// carries `.system`.
|
|
352
|
+
///
|
|
353
|
+
/// Race window: the stamp and the transport call it belongs to are two
|
|
354
|
+
/// separate blocks on `playerQueue`, so an unrelated block landing between
|
|
355
|
+
/// them (e.g. an `.interruption` stamp from `wireAudioSession`) can
|
|
356
|
+
/// overwrite the slot. The practical impact
|
|
357
|
+
/// is bounded — the default reason in `emitStateChangeIfChanged` is
|
|
358
|
+
/// `.system`, so a stolen `.system` re-falls-through to `.system` on
|
|
359
|
+
/// the next consumer. An `.interruption` interleave tags the
|
|
360
|
+
/// state-change as `.interruption` (semantically correct — the
|
|
361
|
+
/// interruption IS why the state changed); the subsequent play()
|
|
362
|
+
/// emit defaults to `.user`. Acceptable degradation.
|
|
363
|
+
///
|
|
364
|
+
/// Togglers: `.togglePlayPause` flips off ANY actively-progressing
|
|
365
|
+
/// state, not just `.playing` — `.buffering` (waiting-to-play)
|
|
366
|
+
/// counts as "user is trying to play" and should pause on tap.
|
|
367
|
+
///
|
|
368
|
+
/// `[weak self]` prevents the cycle TrackPlayer → remoteCommands →
|
|
369
|
+
/// onCommand → TrackPlayer (TrackPlayer owns RemoteCommands as a
|
|
370
|
+
/// `let` property).
|
|
371
|
+
private func wireRemoteCommands() {
|
|
372
|
+
self.remoteCommands.onCommand = { [weak self] command in
|
|
373
|
+
guard let self = self else { return }
|
|
374
|
+
// MPRemoteCommandCenter calls its handlers on main; everything below
|
|
375
|
+
// reads or writes player state, starting with the staked reason.
|
|
376
|
+
self.playerQueue.async {
|
|
377
|
+
self.pendingStateChangeReason = .system
|
|
378
|
+
switch command {
|
|
379
|
+
case .play:
|
|
380
|
+
_ = try? self.play()
|
|
381
|
+
case .pause:
|
|
382
|
+
_ = try? self.pause()
|
|
383
|
+
case .togglePlayPause:
|
|
384
|
+
let active =
|
|
385
|
+
self.lastReportedState == .playing ||
|
|
386
|
+
self.lastReportedState == .buffering
|
|
387
|
+
if active {
|
|
388
|
+
_ = try? self.pause()
|
|
389
|
+
} else {
|
|
390
|
+
_ = try? self.play()
|
|
391
|
+
}
|
|
392
|
+
case .stop:
|
|
393
|
+
_ = try? self.stop()
|
|
394
|
+
case .nextTrack:
|
|
395
|
+
_ = try? self.skipToNext()
|
|
396
|
+
case .previousTrack:
|
|
397
|
+
_ = try? self.skipToPrevious()
|
|
398
|
+
case .skipForward(let seconds):
|
|
399
|
+
// Chromecast: relative-seek the receiver from ITS position. AirPlay /
|
|
400
|
+
// local: `routeSeekBy` returns false (no GCK session), so seek the
|
|
401
|
+
// local engine — whose position is accurate (AVPlayer plays to the
|
|
402
|
+
// AirPlay route). `seekTo` clamps to [0, duration].
|
|
403
|
+
if !CastTransportRouter.routeSeekBy(deltaMs: Int64(seconds * 1000)) {
|
|
404
|
+
let base = self.engine?.currentPositionSeconds ?? 0
|
|
405
|
+
_ = try? self.seekTo(position: (base.isFinite ? base : 0) + seconds)
|
|
406
|
+
}
|
|
407
|
+
case .skipBackward(let seconds):
|
|
408
|
+
if !CastTransportRouter.routeSeekBy(deltaMs: -Int64(seconds * 1000)) {
|
|
409
|
+
let base = self.engine?.currentPositionSeconds ?? 0
|
|
410
|
+
_ = try? self.seekTo(position: (base.isFinite ? base : 0) - seconds)
|
|
411
|
+
}
|
|
412
|
+
case .changePlaybackPosition(let seconds):
|
|
413
|
+
_ = try? self.seekTo(position: seconds)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/// Build the `NowPlayingInfo` snapshot every lock-screen refresh is pushed
|
|
420
|
+
/// with: the current track, and the engine's position, duration and
|
|
421
|
+
/// effective rate.
|
|
422
|
+
///
|
|
423
|
+
/// Every player-side value comes from the engine, and under crossfade the
|
|
424
|
+
/// engine's accessors follow the leg that is audible — so position, duration
|
|
425
|
+
/// and rate describe one leg rather than a mix of two. A `nil` track + zero values when
|
|
426
|
+
/// the player is gone is observed as "queue empty" by
|
|
427
|
+
/// `NowPlayingInfo.refreshAll`, which then clears the dictionary.
|
|
428
|
+
internal func nowPlayingSnapshot() -> NowPlayingInfo.Snapshot {
|
|
429
|
+
let idx = self.currentTrackIndex
|
|
430
|
+
let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
|
|
431
|
+
? self.tracks[idx] : nil
|
|
432
|
+
// The engine owns these, not `self.player`: the gapless queue player does
|
|
433
|
+
// not exist under crossfade.
|
|
434
|
+
let elapsed = self.engine?.currentPositionSeconds ?? 0
|
|
435
|
+
let duration = self.engine?.currentDurationSeconds ?? 0
|
|
436
|
+
// `timeControlStatus`, not `isPlaying`: under crossfade the former follows
|
|
437
|
+
// the audible leg exactly as position and duration do, while the latter
|
|
438
|
+
// reads the leading leg alone. Taking `isPlaying` here would report rate 0
|
|
439
|
+
// whenever the outgoing item has ended but the fade has not yet completed,
|
|
440
|
+
// pairing a live position with a paused rate — and it is the same source
|
|
441
|
+
// `PlayerStateDerivation` uses, so the pushed rate and the emitted state
|
|
442
|
+
// cannot disagree.
|
|
443
|
+
//
|
|
444
|
+
// MediaPlayer wants the effective rate: the configured speed while
|
|
445
|
+
// playing, zero otherwise. Publishing a requested-but-not-yet-audible rate
|
|
446
|
+
// makes the system extrapolate the scrubber through a stall.
|
|
447
|
+
let rate = (self.engine?.timeControlStatus == .playing) ? self.playbackSpeedState : 0
|
|
448
|
+
return NowPlayingInfo.Snapshot(
|
|
449
|
+
track: track,
|
|
450
|
+
elapsedSeconds: elapsed,
|
|
451
|
+
durationSeconds: duration,
|
|
452
|
+
rate: rate
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/// Drive the 10s lock-screen tick. The snapshot is built here, on the
|
|
457
|
+
/// side that owns player state, and pushed into `NowPlayingInfo`.
|
|
458
|
+
/// A paused player short-circuits — elapsed is unchanged and rate is
|
|
459
|
+
/// already 0, so the write would be pure noise.
|
|
460
|
+
private func startNowPlayingPeriodicTimer() {
|
|
461
|
+
self.nowPlayingInfo.startPeriodicTimer { [weak self] in
|
|
462
|
+
guard let self else { return }
|
|
463
|
+
// The tick fires on main, where the Now Playing surface lives, but the
|
|
464
|
+
// snapshot is player state — so it is built on the queue that owns that
|
|
465
|
+
// state and handed back as a value.
|
|
466
|
+
self.playerQueue.async {
|
|
467
|
+
let snapshot = self.nowPlayingSnapshot()
|
|
468
|
+
guard snapshot.rate != 0 else { return }
|
|
469
|
+
self.nowPlayingInfo.refreshPositionAndRate(snapshot)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
func destroy() throws -> Promise<Void> {
|
|
475
|
+
return enqueue {
|
|
476
|
+
self.internalDestroy()
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/// Synchronous teardown. Idempotent — cancels the timer sources, then
|
|
481
|
+
/// no-ops when nothing is configured. Called from public `destroy()`
|
|
482
|
+
/// and from `configure()` (which auto-destroys before reinitialising;
|
|
483
|
+
/// see `configure(_:)` for the universal contract).
|
|
484
|
+
///
|
|
485
|
+
/// Persists `config` / `repeatModeState` / `lookaheadConfig` across
|
|
486
|
+
/// the teardown so a consumer who called `setRepeatMode()` /
|
|
487
|
+
/// `setLookaheadCache()` doesn't lose those settings on a
|
|
488
|
+
/// reconfigure cycle. Matches Android's symmetric behaviour.
|
|
489
|
+
private func internalDestroy() {
|
|
490
|
+
// Ahead of the gate below: `setSleepTimer` / `setSleepTimerToTrackEnd`
|
|
491
|
+
// arm without an engine, so a gated teardown would strand a live 2 Hz
|
|
492
|
+
// tick source with nothing left to cancel it. All three are idempotent
|
|
493
|
+
// and none read `engine`.
|
|
494
|
+
self.stopProgressFallbackTimer()
|
|
495
|
+
self.stopSleepTimerTick()
|
|
496
|
+
self.sleepTimerCore.clear()
|
|
497
|
+
// Idempotent gate: when nothing has been configured, every line
|
|
498
|
+
// below would either no-op (nil-coalesced calls) or perform
|
|
499
|
+
// unnecessary work against fresh state. `self.engine` is the
|
|
500
|
+
// "is configured?" sentinel because it is non-nil under BOTH
|
|
501
|
+
// engines. `self.player` is not — it holds the gapless
|
|
502
|
+
// `AVQueuePlayer` and stays nil for as long as a CrossfadeEngine
|
|
503
|
+
// is active, so gating on it would skip the whole teardown
|
|
504
|
+
// mid-crossfade and leak every resource below.
|
|
505
|
+
//
|
|
506
|
+
// `configure(config:)` assigns `self.engine` before
|
|
507
|
+
// `audioSession.configureCategory`, `audioSession.installObservers`,
|
|
508
|
+
// `startNowPlayingPeriodicTimer` and `remoteCommands.install`, so the
|
|
509
|
+
// gate admits cleanup of every resource those install. The tear-down
|
|
510
|
+
// lines below are individually idempotent.
|
|
511
|
+
// Above the gate: the tap provider is a process-wide singleton, so an
|
|
512
|
+
// instance that configured and is then dropped without `destroy()` would
|
|
513
|
+
// otherwise leave the singleton pointing at a dead player's queue, and
|
|
514
|
+
// every later player's mix work would dispatch onto it.
|
|
515
|
+
AudioTapProvider.shared.detachFromEngine()
|
|
516
|
+
// The provider is process-wide, so a callback left installed on it outlives
|
|
517
|
+
// this player and would run against the one being torn down.
|
|
518
|
+
AudioTapProvider.shared.onItemReplayGainDataPopulated = nil
|
|
519
|
+
AudioTapProvider.shared.resetMixDispatchTarget()
|
|
520
|
+
|
|
521
|
+
guard self.engine != nil else { return }
|
|
522
|
+
// Tear the prefetcher down BEFORE the cache. Cancelling the
|
|
523
|
+
// prefetcher's Task chain arms cooperative cancellation so no
|
|
524
|
+
// new download dispatches against the about-to-die URLSession;
|
|
525
|
+
// the cache's `tearDown` then invalidates the session, which
|
|
526
|
+
// is what actually kills any already-suspended
|
|
527
|
+
// URLSessionDownloadTask mid-flight.
|
|
528
|
+
self.lookaheadCachePrefetcher?.tearDown()
|
|
529
|
+
self.lookaheadCachePrefetcher = nil
|
|
530
|
+
// Before the cache: a connection mid-response holds an open handle on a
|
|
531
|
+
// cache file, and the listener is what owns those connections.
|
|
532
|
+
self.readThroughServer?.stop()
|
|
533
|
+
self.readThroughServer = nil
|
|
534
|
+
self.lookaheadCache?.tearDown()
|
|
535
|
+
self.lookaheadCache = nil
|
|
536
|
+
// Drop the mirror chain rather than cancelling it: a receiver call already
|
|
537
|
+
// on the wire is left to finish, and the next session starts its own chain
|
|
538
|
+
// instead of queueing behind a torn-down one.
|
|
539
|
+
self.castMirrorChain = nil
|
|
540
|
+
|
|
541
|
+
self.tearDownGaplessObservers()
|
|
542
|
+
self.tearDownEventObservers()
|
|
543
|
+
self.removeAppLifecycleObservers()
|
|
544
|
+
self.networkMonitor?.stop()
|
|
545
|
+
self.networkMonitor = nil
|
|
546
|
+
self.audioSession.tearDownObservers()
|
|
547
|
+
self.audioSession.onInterruption = nil
|
|
548
|
+
self.audioSession.onRouteChange = nil
|
|
549
|
+
self.audioSession.onError = nil
|
|
550
|
+
self.nowPlayingInfo.clear()
|
|
551
|
+
self.remoteCommands.uninstall()
|
|
552
|
+
self.remoteCommands.onCommand = nil
|
|
553
|
+
self.artworkResolver.reset()
|
|
554
|
+
// The tap provider is a process-wide singleton: drop its pointer at the
|
|
555
|
+
// engine and put its dispatch target back on main, so the next
|
|
556
|
+
// `configure` starts from the clean slate its contract promises.
|
|
557
|
+
// CarPlay coordinator state lives for process lifetime. The
|
|
558
|
+
// bridge slots survive destroy — the subscription is owned by
|
|
559
|
+
// the JS consumer (re-registering replaces the slot), matching
|
|
560
|
+
// the documented Android behaviour. Browse data, in-flight
|
|
561
|
+
// resolver promises, and the artwork cache reset with the
|
|
562
|
+
// configure cycle.
|
|
563
|
+
let coordinator = CarPlayCoordinator.shared
|
|
564
|
+
coordinator.dataProvider.setSnapshot(nil)
|
|
565
|
+
coordinator.queueProvider.setSnapshot(nil)
|
|
566
|
+
coordinator.skipToIndexHandler = nil
|
|
567
|
+
coordinator.onCarAttached = nil
|
|
568
|
+
coordinator.pending.clear()
|
|
569
|
+
coordinator.loader.clearCache()
|
|
570
|
+
// engine.release() drains the underlying AVQueuePlayer's items +
|
|
571
|
+
// nils its mix provider.
|
|
572
|
+
self.engine?.release()
|
|
573
|
+
self.engine = nil
|
|
574
|
+
self.player = nil
|
|
575
|
+
self.sleepTimerFading = false
|
|
576
|
+
self.queueState.clear()
|
|
577
|
+
// The clear emits nothing, so a mirror built from `onQueueChange` deltas
|
|
578
|
+
// would splice the next delta onto the queue this just dropped. Moving
|
|
579
|
+
// the revision past the next emit's value is what a subscriber reads as
|
|
580
|
+
// a missed event, and re-reads.
|
|
581
|
+
self.queueRevision += 1
|
|
582
|
+
self.currentTrackIndex = -1
|
|
583
|
+
self.currentTrackSource = nil
|
|
584
|
+
self.currentBufferState = .empty
|
|
585
|
+
self.lastEmittedBufferState = nil
|
|
586
|
+
self.lastBufferStateQueueItemId = nil
|
|
587
|
+
self.currentFullyBuffered = false
|
|
588
|
+
self.lastEmittedFullyBuffered = nil
|
|
589
|
+
self.hasStartedPlaying = false
|
|
590
|
+
self.wantsToPlay = false
|
|
591
|
+
self.isInterrupted = false
|
|
592
|
+
self.isRecoveringFromStall = false
|
|
593
|
+
self.engineReconcileDeferred = false
|
|
594
|
+
self.lastEmittedQueueItemId = nil
|
|
595
|
+
self.didPlayToEndPending = false
|
|
596
|
+
// `engine` + `player` are both nil now, so `activeMediaItem` is
|
|
597
|
+
// nil: refresh emits null + cancels in-flight extractor Tasks via
|
|
598
|
+
// the generation token.
|
|
599
|
+
self.refreshNowPlayingFormatForActiveItem()
|
|
600
|
+
// `lastErrorQueueItemId` / `lastErrorCode` reset alongside the
|
|
601
|
+
// player so a subsequent error on a different item still fires
|
|
602
|
+
// (the dedup is per-item).
|
|
603
|
+
self.lastErrorQueueItemId = nil
|
|
604
|
+
self.lastErrorCode = nil
|
|
605
|
+
self.pendingRetryQueueItemId = nil
|
|
606
|
+
self.retryAttemptsRemaining.removeAll()
|
|
607
|
+
self.lastReportedState = .none
|
|
608
|
+
self.reachedQueueEnd = false
|
|
609
|
+
self.pendingTrackChangeReason = nil
|
|
610
|
+
self.pendingStateChangeReason = nil
|
|
611
|
+
self.audioSession.deactivate()
|
|
612
|
+
// Tear down the cast event bridge — disposers unsubscribe from
|
|
613
|
+
// both the global router and any active session's state listener.
|
|
614
|
+
self.castEventBridge.stop()
|
|
615
|
+
// Tear down the cast lockscreen mirror.
|
|
616
|
+
CastNowPlayingController.shared.stop()
|
|
617
|
+
// Final (false, false) emit so any still-registered subscriber
|
|
618
|
+
// sees the player is gone. JS hooks dispose their listener on
|
|
619
|
+
// unmount; this covers stragglers + native-side subscribers.
|
|
620
|
+
self.recomputeCapabilities()
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/// Attach the KVO chain that powers the stalling-flip gapless
|
|
624
|
+
/// pattern: observe `player.currentItem` for item transitions,
|
|
625
|
+
/// and for each current item observe `status` so we can flip
|
|
626
|
+
/// `automaticallyWaitsToMinimizeStalling` to false the moment
|
|
627
|
+
/// the current item reaches `.readyToPlay` while
|
|
628
|
+
/// `gaplessFlipArmed == true`. Safe to call repeatedly — previous
|
|
629
|
+
/// observers are invalidated first.
|
|
630
|
+
///
|
|
631
|
+
/// `.new`-only KVO: `.initial` is deliberately NOT used. `.initial`
|
|
632
|
+
/// fires synchronously during observer registration on whatever
|
|
633
|
+
/// thread the install runs from, which can land mid-mutation and
|
|
634
|
+
/// skip the lib's debounce step (`pendingStateChangeReason`
|
|
635
|
+
/// consumption, `lastEmittedQueueItemId` dedup). The explicit
|
|
636
|
+
/// `playerQueue.async` bootstrap below replicates `.initial` semantics
|
|
637
|
+
/// by funnelling through the same handler the `.new` callback
|
|
638
|
+
/// uses, but on a stable post-registration state. See NOTES.md §18.
|
|
639
|
+
internal func installGaplessObservers(on player: AVQueuePlayer) {
|
|
640
|
+
tearDownGaplessObservers()
|
|
641
|
+
|
|
642
|
+
currentItemObserver = player.observe(
|
|
643
|
+
\.currentItem, options: [.new]
|
|
644
|
+
) { [weak self] _, _ in
|
|
645
|
+
// KVO fires on AVFoundation's internal queue; funnel onto the queue
|
|
646
|
+
// that owns player state, like every other player mutation.
|
|
647
|
+
guard let self else { return }
|
|
648
|
+
self.playerQueue.async {
|
|
649
|
+
self.attachStatusObserverIfArmed()
|
|
650
|
+
self.handleCurrentItemDidChange()
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Bootstrap to replicate what `.initial` would have delivered. The async
|
|
654
|
+
// hop matches the `.new` callback body's dispatch shape so a fresh-install
|
|
655
|
+
// cur-item is observed identically to a subsequent transition.
|
|
656
|
+
playerQueue.async { [weak self] in
|
|
657
|
+
guard let self else { return }
|
|
658
|
+
self.attachStatusObserverIfArmed()
|
|
659
|
+
self.handleCurrentItemDidChange()
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private func attachStatusObserverIfArmed() {
|
|
664
|
+
currentItemStatusObserver?.invalidate()
|
|
665
|
+
currentItemStatusObserver = nil
|
|
666
|
+
currentItemDurationObserver?.invalidate()
|
|
667
|
+
currentItemDurationObserver = nil
|
|
668
|
+
tearDownBufferObservers()
|
|
669
|
+
guard let item = self.player?.currentItem else {
|
|
670
|
+
// No current item (empty queue / torn down) — settle to empty.
|
|
671
|
+
recomputeBufferState()
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// A local file that does not exist is deterministically unplayable, and its
|
|
676
|
+
// `AVURLAsset` load can stall in `.unknown` indefinitely under host load —
|
|
677
|
+
// the item never reaches `.failed`, so the `\.status` observer below would
|
|
678
|
+
// never surface it and the queue sits buffering with no error. Fail it now
|
|
679
|
+
// and install no observer for this dead item: a status observer that did
|
|
680
|
+
// eventually reach `.failed` would re-report the same source under a
|
|
681
|
+
// second, different error code. A retry re-seats
|
|
682
|
+
// the item and re-runs this check, so a source that appears is picked up
|
|
683
|
+
// then. Pausing first halts the AVQueuePlayer chain-advance the same way the
|
|
684
|
+
// `.failed` branch does. Read the asset's own URL, the one the player loads.
|
|
685
|
+
if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
|
|
686
|
+
!FileManager.default.fileExists(atPath: asset.url.path) {
|
|
687
|
+
self.player?.pause()
|
|
688
|
+
let missing = NSError(
|
|
689
|
+
domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist,
|
|
690
|
+
userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."])
|
|
691
|
+
self.dispatchErrorOrRetry(
|
|
692
|
+
item: item, underlying: missing, nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED")
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Two distinct concerns share this KVO:
|
|
697
|
+
// 1. `.readyToPlay` flips `automaticallyWaitsToMinimizeStalling`
|
|
698
|
+
// back to false (initial-buffer leniency one-shot).
|
|
699
|
+
// 2. `.failed` surfaces an `onError` event — the only hook that
|
|
700
|
+
// catches initial-load failures (closed port, 4xx/5xx HTTP,
|
|
701
|
+
// malformed asset). Mid-playback failures arrive separately
|
|
702
|
+
// via `AVPlayerItemFailedToPlayToEndTime`.
|
|
703
|
+
//
|
|
704
|
+
// `.new`-only KVO + synchronous bootstrap. The bootstrap is sync
|
|
705
|
+
// (not a `playerQueue.async` hop like the sibling `\.currentItem` /
|
|
706
|
+
// `\.timeControlStatus` bootstraps) because this method is itself
|
|
707
|
+
// called on `playerQueue` (from the `\.currentItem` KVO body's hop)
|
|
708
|
+
// — running the bootstrap inline catches a cache-hit path
|
|
709
|
+
// where the asset resolves to `.readyToPlay` before observer
|
|
710
|
+
// install would otherwise leave `gaplessFlipArmed` set forever.
|
|
711
|
+
//
|
|
712
|
+
// Re-entrancy: a `.failed` immediate resolution dispatches through
|
|
713
|
+
// `handleCurrentItemFailedToLoad` → `dispatchErrorOrRetry`, which
|
|
714
|
+
// may schedule a retry that calls `fullRebuildPlayerQueue`. The
|
|
715
|
+
// rebuild's `tearDownGaplessObservers` invalidates the just-
|
|
716
|
+
// installed observer reference; the synchronous bootstrap's local
|
|
717
|
+
// `let observer` reference still holds but its observation is
|
|
718
|
+
// disconnected. Safe — the inflight bootstrap path completes,
|
|
719
|
+
// returns to `attachStatusObserverIfArmed`, returns to the parent
|
|
720
|
+
// KVO body, and the rebuild's freshly-installed observer takes
|
|
721
|
+
// over.
|
|
722
|
+
currentItemStatusObserver = item.observe(
|
|
723
|
+
\.status, options: [.new]
|
|
724
|
+
) { [weak self] item, _ in
|
|
725
|
+
guard let self else { return }
|
|
726
|
+
self.playerQueue.async {
|
|
727
|
+
self.dispatchItemStatus(item)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
self.dispatchItemStatus(item)
|
|
731
|
+
currentItemDurationObserver = item.observe(
|
|
732
|
+
\.duration, options: [.new]
|
|
733
|
+
) { [weak self] _, _ in
|
|
734
|
+
guard let self else { return }
|
|
735
|
+
self.playerQueue.async {
|
|
736
|
+
self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
installBufferObservers(on: item)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/// KVO on the gapless current item's three native playback-buffer flags.
|
|
743
|
+
/// Any change recomputes the `BufferState` and the fully-buffered flag on
|
|
744
|
+
/// `playerQueue`, ahead of the next progress tick.
|
|
745
|
+
private func installBufferObservers(on item: AVPlayerItem) {
|
|
746
|
+
let onChange: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
|
|
747
|
+
self?.playerQueue.async {
|
|
748
|
+
self?.recomputeBufferState()
|
|
749
|
+
self?.recomputeFullyBuffered()
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new], changeHandler: onChange)
|
|
753
|
+
bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onChange)
|
|
754
|
+
bufferFullObserver = item.observe(\.isPlaybackBufferFull, options: [.new], changeHandler: onChange)
|
|
755
|
+
recomputeBufferState()
|
|
756
|
+
recomputeFullyBuffered()
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private func tearDownBufferObservers() {
|
|
760
|
+
bufferEmptyObserver?.invalidate(); bufferEmptyObserver = nil
|
|
761
|
+
bufferKeepUpObserver?.invalidate(); bufferKeepUpObserver = nil
|
|
762
|
+
bufferFullObserver?.invalidate(); bufferFullObserver = nil
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/// Single source of truth for the per-item `\.status` KVO body.
|
|
766
|
+
/// Called both from the observer callback and from the synchronous
|
|
767
|
+
/// bootstrap in `attachStatusObserverIfArmed`.
|
|
768
|
+
private func dispatchItemStatus(_ item: AVPlayerItem) {
|
|
769
|
+
switch item.status {
|
|
770
|
+
case .readyToPlay:
|
|
771
|
+
if self.gaplessFlipArmed, let player = self.player {
|
|
772
|
+
player.automaticallyWaitsToMinimizeStalling = false
|
|
773
|
+
self.gaplessFlipArmed = false
|
|
774
|
+
}
|
|
775
|
+
// The asset's audio track + format descriptions are reliably
|
|
776
|
+
// loadable by the time we hit `.readyToPlay`. The post-track-
|
|
777
|
+
// change null-emit may race ahead of asset metadata load on
|
|
778
|
+
// first hit, so we re-resolve here to land the real format.
|
|
779
|
+
self.refreshNowPlayingFormatForActiveItem()
|
|
780
|
+
// Streaming-item audioMix catch-up for the gapless engine.
|
|
781
|
+
// engine.setItems / insertItems install the mix at insert
|
|
782
|
+
// time only for items whose `tracks` key is already loaded;
|
|
783
|
+
// streaming items insert with `audioMix == nil` and pick up
|
|
784
|
+
// their mix here once `.readyToPlay` confirms tracks have
|
|
785
|
+
// loaded. The crossfade engine has a parallel catch-up path
|
|
786
|
+
// through its `engineActiveItemFormatChanged` delegate hook —
|
|
787
|
+
// both call into `refreshActiveItemMixes`, which is
|
|
788
|
+
// idempotent on items that already carry a tap, so a double-
|
|
789
|
+
// fire is harmless.
|
|
790
|
+
AudioTapProvider.shared.refreshActiveItemMixes()
|
|
791
|
+
// Duration is only reliably readable once the item is `.readyToPlay`.
|
|
792
|
+
// Re-publish now-playing so the lock-screen scrubber gets a duration on
|
|
793
|
+
// first load + auto-advance (not just after a manual skip, which is the
|
|
794
|
+
// only path that otherwise forces a state change that re-publishes it).
|
|
795
|
+
self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
|
|
796
|
+
case .failed:
|
|
797
|
+
// Pause synchronously BEFORE surfacing the typed error. Without
|
|
798
|
+
// this, AVQueuePlayer treats `.failed` as end-of-item and chain-
|
|
799
|
+
// advances through subsequent items in the queue, walking
|
|
800
|
+
// `currentTrackIndex` past the failure point while the JS error
|
|
801
|
+
// event is still in flight. Pausing halts that walk so the
|
|
802
|
+
// consumer sees the error against the failing track and can
|
|
803
|
+
// decide whether to skip / retry / surface UI.
|
|
804
|
+
self.player?.pause()
|
|
805
|
+
self.handleCurrentItemFailedToLoad(item)
|
|
806
|
+
default:
|
|
807
|
+
break
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/// Surface a `.failed` AVPlayerItem as an `onError` + state ERROR.
|
|
812
|
+
/// Idempotent per (queueItemId, standardized-code) so AVPlayer's
|
|
813
|
+
/// internal item-rebuild during retries doesn't bypass dedup. A
|
|
814
|
+
/// retry that flips the same item to a DIFFERENT error code still
|
|
815
|
+
/// surfaces because the code is part of the dedup key.
|
|
816
|
+
///
|
|
817
|
+
/// `nativeDomain` on the emitted error is stamped to
|
|
818
|
+
/// `PLAYER_ITEM_LOAD_FAILED` so JS consumers can distinguish item-
|
|
819
|
+
/// load failures (the raw asset never reached `.readyToPlay`) from
|
|
820
|
+
/// mid-playback failures (which arrive via
|
|
821
|
+
/// `AVPlayerItemFailedToPlayToEndTime` and keep the underlying
|
|
822
|
+
/// `NSError.domain`). The underlying NSError code + message remain
|
|
823
|
+
/// available via `nativeCode` + `nativeMessage`.
|
|
824
|
+
private func handleCurrentItemFailedToLoad(_ item: AVPlayerItem) {
|
|
825
|
+
guard item.status == .failed else { return }
|
|
826
|
+
let underlying = item.error as NSError?
|
|
827
|
+
dispatchErrorOrRetry(
|
|
828
|
+
item: item,
|
|
829
|
+
underlying: underlying,
|
|
830
|
+
nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED"
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/// Shared dispatch path for `handleCurrentItemFailedToLoad` +
|
|
835
|
+
/// `handlePlayerItemFailedToPlayToEndTime`. Encapsulates: (1)
|
|
836
|
+
/// classifier, (2) qid-fallback to fresh UUID for raw items, (3)
|
|
837
|
+
/// (queueItemId, code) dedup, (4) auto-retry path with dedup
|
|
838
|
+
/// clear-on-retry, (5) terminal `errorListeners.forEach` +
|
|
839
|
+
/// emitState(.error). Single chokepoint so the dedup + retry
|
|
840
|
+
/// contracts can't drift between callers.
|
|
841
|
+
///
|
|
842
|
+
/// `nativeDomainOverride` lets a caller stamp a stable lib-defined
|
|
843
|
+
/// SCREAMING_SNAKE_CASE marker on the emitted `nativeDomain` (e.g.
|
|
844
|
+
/// `PLAYER_ITEM_LOAD_FAILED` from the `.failed`-status path) so JS
|
|
845
|
+
/// consumers can branch on the cause without parsing raw native
|
|
846
|
+
/// domain strings. Nil preserves the underlying `NSError.domain`.
|
|
847
|
+
internal func dispatchErrorOrRetry(
|
|
848
|
+
item: AVPlayerItem?,
|
|
849
|
+
underlying: NSError?,
|
|
850
|
+
nativeDomainOverride: String? = nil
|
|
851
|
+
) {
|
|
852
|
+
let mapped = PlaybackErrorMapping.classify(underlying)
|
|
853
|
+
// Fresh UUID fallback per fire (NOT a static sentinel) when
|
|
854
|
+
// queueItemId is missing — two unrelated items both lacking the
|
|
855
|
+
// associated-object would otherwise dedup against each other.
|
|
856
|
+
// Production AVQueueBuilder.makeItem always sets queueItemId; this
|
|
857
|
+
// path covers raw `makeItemRaw` items (test stubs).
|
|
858
|
+
let qid = item?.queueItemId ?? UUID().uuidString
|
|
859
|
+
// An echo of a failure whose attempt has not run yet belongs to that
|
|
860
|
+
// attempt, not to the consumer.
|
|
861
|
+
if let qidReal = item?.queueItemId, self.pendingRetryQueueItemId == qidReal { return }
|
|
862
|
+
if self.lastErrorQueueItemId == qid && self.lastErrorCode == mapped { return }
|
|
863
|
+
self.lastErrorQueueItemId = qid
|
|
864
|
+
self.lastErrorCode = mapped
|
|
865
|
+
// Auto-retry path: transient errors with retries left schedule
|
|
866
|
+
// a delayed rebuild. Skip JS emit + state.error transition until
|
|
867
|
+
// retries exhausted.
|
|
868
|
+
if PlaybackErrorMapping.isTransient(mapped),
|
|
869
|
+
let qidReal = item?.queueItemId,
|
|
870
|
+
(self.retryAttemptsRemaining[qidReal] ?? 0) > 0 {
|
|
871
|
+
self.retryAttemptsRemaining[qidReal] = self.retryAttemptsRemaining[qidReal]! - 1
|
|
872
|
+
self.pendingRetryQueueItemId = qidReal
|
|
873
|
+
// Clear dedup so the retry-then-fail (if any) fires fresh.
|
|
874
|
+
self.lastErrorQueueItemId = nil
|
|
875
|
+
self.lastErrorCode = nil
|
|
876
|
+
let backoff = self.effectiveRetryBackoffMs()
|
|
877
|
+
playerQueue.asyncAfter(deadline: .now() + .milliseconds(backoff)) {
|
|
878
|
+
[weak self] in self?.retryFailedItem(queueItemId: qidReal)
|
|
879
|
+
}
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
// Surface the failing item's queueItemId + URL so consumers can
|
|
883
|
+
// correlate the error to the specific track even when
|
|
884
|
+
// AVQueuePlayer chain-advances past it before the JS event fires.
|
|
885
|
+
// Reuse `qid` (which is the lib-generated UUID fallback for raw
|
|
886
|
+
// items) so the JS-visible field matches the dedup key.
|
|
887
|
+
let failedQid = item?.queueItemId ?? qid
|
|
888
|
+
// The track's own URL. A routed item's asset URL names the read-through
|
|
889
|
+
// server, which tells a consumer nothing about which track failed — and
|
|
890
|
+
// correlating the error to a track is the whole purpose of this field.
|
|
891
|
+
let failedUrl = item?.sourceURL ?? (item?.asset as? AVURLAsset)?.url.absoluteString ?? ""
|
|
892
|
+
let err = self.buildPlaybackError(
|
|
893
|
+
underlying,
|
|
894
|
+
mapped: mapped,
|
|
895
|
+
queueItemId: failedQid,
|
|
896
|
+
url: failedUrl,
|
|
897
|
+
nativeDomainOverride: nativeDomainOverride
|
|
898
|
+
)
|
|
899
|
+
self.errorListeners.forEach { $0(err) }
|
|
900
|
+
self.emitState(.error, reason: .error)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
internal func tearDownGaplessObservers() {
|
|
904
|
+
currentItemObserver?.invalidate()
|
|
905
|
+
currentItemObserver = nil
|
|
906
|
+
currentItemStatusObserver?.invalidate()
|
|
907
|
+
currentItemStatusObserver = nil
|
|
908
|
+
currentItemDurationObserver?.invalidate()
|
|
909
|
+
currentItemDurationObserver = nil
|
|
910
|
+
tearDownBufferObservers()
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/// Re-arm the initial-buffer leniency: the next `.readyToPlay`
|
|
914
|
+
/// flips stalling back to false. Called from `fullRebuildPlayerQueue`
|
|
915
|
+
/// (central choke point for "the currentItem is being replaced by
|
|
916
|
+
/// a fresh AVPlayerItem") and from `removeFromQueue` when the
|
|
917
|
+
/// currentItem is dropped and AVQueuePlayer auto-advances to an
|
|
918
|
+
/// item that may not yet be buffered.
|
|
919
|
+
///
|
|
920
|
+
/// The `\.currentItem` KVO chain already installed in `configure`
|
|
921
|
+
/// handles attaching the status observer once the new currentItem
|
|
922
|
+
/// is in place — we don't attach it synchronously here to avoid
|
|
923
|
+
/// racing against the caller's in-flight `player.insert(...)`
|
|
924
|
+
/// sequence.
|
|
925
|
+
internal func rearmGaplessFlip() {
|
|
926
|
+
guard let player = self.player else { return }
|
|
927
|
+
self.gaplessFlipArmed = true
|
|
928
|
+
player.automaticallyWaitsToMinimizeStalling = true
|
|
929
|
+
}
|
|
930
|
+
}
|