react-native-queue-player 1.2.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QueuePlayer.podspec +14 -9
- package/README.md +3 -3
- package/android/consumer-rules.pro +1 -1
- package/android/src/main/cpp/airplay2_control.c +4 -4
- package/android/src/main/cpp/airplay2_control.h +4 -4
- package/android/src/main/cpp/airplay2_jni.cpp +3 -19
- package/android/src/main/cpp/airplay2_pair.c +7 -7
- package/android/src/main/cpp/airplay2_pair.h +2 -2
- package/android/src/main/cpp/airplay2_rtsp.c +8 -8
- package/android/src/main/cpp/airplay2_session.c +2 -2
- package/android/src/main/cpp/airplay_jni.cpp +8 -69
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
- package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
- package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
- package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
- package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +111 -29
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +78 -30
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +13 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +985 -276
- package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
- package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +84 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +122 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +291 -27
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
- package/app.plugin.js +17 -1
- package/ios/AVPlayerItemQueueItemId.swift +26 -12
- package/ios/AVQueueBuilder.swift +120 -85
- package/ios/ArtworkLoader.swift +0 -7
- package/ios/ArtworkResolver.swift +22 -4
- package/ios/AssetReplayGainReader.swift +118 -0
- package/ios/AudioSession.swift +67 -16
- package/ios/AudioTapProvider.swift +284 -149
- package/ios/CarPlayBridge.swift +0 -12
- package/ios/CarPlayCoordinator.swift +18 -2
- package/ios/CarPlaySceneDelegate.swift +2 -2
- package/ios/Cast/AirPlayRouteState.swift +9 -8
- package/ios/Cast/Core/CastEventBridge.swift +66 -26
- package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
- package/ios/Cast/Core/CastSession.swift +2 -2
- package/ios/Cast/Core/CastTransportRouter.swift +21 -9
- package/ios/Cast/Core/LocalMediaServer.swift +41 -52
- package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
- package/ios/Cast/Core/MediaServerHandle.swift +0 -4
- package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
- package/ios/CastManager.swift +1 -1
- package/ios/CrossfadeEngine.swift +672 -226
- package/ios/EQTap.swift +20 -17
- package/ios/Equalizer.swift +1 -1
- package/ios/FLACStreamInfo.swift +65 -0
- package/ios/GaplessEngine.swift +57 -30
- package/ios/InputGuards.swift +35 -5
- package/ios/LookaheadCache.swift +229 -58
- package/ios/LookaheadCachePrefetcher.swift +48 -36
- package/ios/MediaResponseCheck.swift +122 -0
- package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
- package/ios/NetworkRecoveryPolicy.swift +32 -0
- package/ios/NowPlayingFormatExtractor.swift +25 -18
- package/ios/NowPlayingInfo.swift +84 -48
- package/ios/OutputRouteMonitor.swift +16 -3
- package/ios/PendingBrowseRequests.swift +1 -1
- package/ios/PlaceholderArtwork.swift +9 -5
- package/ios/PlaybackEngine.swift +165 -76
- package/ios/PlaybackErrorMapping.swift +3 -3
- package/ios/PlaybackModeStateMachine.swift +2 -2
- package/ios/PlaybackNetworkMonitor.swift +73 -0
- package/ios/PlayerStateDerivation.swift +6 -6
- package/ios/QueueMutationArithmetic.swift +25 -0
- package/ios/QueueSkipArithmetic.swift +5 -3
- package/ios/QueueWindowArithmetic.swift +152 -0
- package/ios/ReadThroughServer.swift +283 -0
- package/ios/RemoteCommands.swift +44 -4
- package/ios/ReplayGainData.swift +22 -3
- package/ios/ReplayGainExtractor.swift +70 -27
- package/ios/Siri/VoiceDonation.swift +14 -9
- package/ios/StreamingBitrateProbe.swift +22 -68
- package/ios/SuppliedReplayGain.swift +83 -0
- package/ios/Tests/AVQueueBuilderTests.swift +158 -221
- package/ios/Tests/ActiveItemEngineStub.swift +44 -0
- package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
- package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
- package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
- package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
- package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
- package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
- package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
- package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
- package/ios/Tests/CrossfadeEngineTests.swift +164 -0
- package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
- package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
- package/ios/Tests/EngineSwapTests.swift +91 -0
- package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
- package/ios/Tests/EqualizerHybridTests.swift +5 -2
- package/ios/Tests/FLACStreamInfoTests.swift +88 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
- package/ios/Tests/InputGuardsTests.swift +41 -0
- package/ios/Tests/InterruptionIntentTests.swift +77 -0
- package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
- package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
- package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
- package/ios/Tests/LookaheadCacheTests.swift +44 -18
- package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
- package/ios/Tests/MediaResponseCheckTests.swift +128 -0
- package/ios/Tests/MutationDeferralTests.swift +220 -0
- package/ios/Tests/NowPlayingInfoTests.swift +86 -99
- package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
- package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
- package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
- package/ios/Tests/PlayerFixtures.swift +48 -0
- package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
- package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
- package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
- package/ios/Tests/QueueStateTests.swift +3 -1
- package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
- package/ios/Tests/QueueWindowSliceTests.swift +162 -0
- package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
- package/ios/Tests/ReadThroughServerTests.swift +345 -0
- package/ios/Tests/RemoteCommandsTests.swift +28 -0
- package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
- package/ios/Tests/ReplayGainMergeTests.swift +230 -0
- package/ios/Tests/RetryRecoveryTests.swift +418 -0
- package/ios/Tests/SkipCapabilityTests.swift +233 -8
- package/ios/Tests/SkipIndexTests.swift +32 -2
- package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
- package/ios/Tests/StallRecoveryTests.swift +97 -0
- package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
- package/ios/Tests/TopUpWindowGateTests.swift +441 -0
- package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
- package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
- package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
- package/ios/Tests/TrackPlayerEndVerdictTests.swift +206 -0
- package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
- package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
- package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
- package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
- package/ios/TrackPlayer+Automotive.swift +101 -0
- package/ios/TrackPlayer+Cache.swift +232 -0
- package/ios/TrackPlayer+Config.swift +291 -0
- package/ios/TrackPlayer+EngineDelegate.swift +209 -0
- package/ios/TrackPlayer+EventsAPI.swift +111 -0
- package/ios/TrackPlayer+EventsDispatch.swift +1042 -0
- package/ios/TrackPlayer+EventsWiring.swift +173 -0
- package/ios/TrackPlayer+Lifecycle.swift +982 -0
- package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
- package/ios/TrackPlayer+Queue.swift +951 -0
- package/ios/TrackPlayer+Recovery.swift +213 -0
- package/ios/TrackPlayer+Skip.swift +465 -0
- package/ios/TrackPlayer+SleepTimer.swift +175 -0
- package/ios/TrackPlayer+State.swift +108 -0
- package/ios/TrackPlayer+Threading.swift +135 -0
- package/ios/TrackPlayer+Transport.swift +272 -0
- package/ios/TrackPlayer+Window.swift +227 -0
- package/ios/TrackPlayer.swift +331 -4619
- package/ios/Visualizer.swift +6 -5
- package/ios/tests-harness/Podfile +1 -1
- package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
- package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
- package/lib/module/hooks/useActiveTrack.js +29 -22
- package/lib/module/hooks/useActiveTrack.js.map +1 -1
- package/lib/module/hooks/useCast.js +8 -26
- package/lib/module/hooks/useCast.js.map +1 -1
- package/lib/module/hooks/useEqualizer.js +19 -12
- package/lib/module/hooks/useEqualizer.js.map +1 -1
- package/lib/module/hooks/useLookaheadCache.js +3 -7
- package/lib/module/hooks/useLookaheadCache.js.map +1 -1
- package/lib/module/hooks/useQueue.js +66 -19
- package/lib/module/hooks/useQueue.js.map +1 -1
- package/lib/module/index.js +6 -3
- package/lib/module/index.js.map +1 -1
- package/lib/module/queueDelta.js +41 -0
- package/lib/module/queueDelta.js.map +1 -0
- package/lib/module/types.js +28 -5
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
- package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
- package/lib/typescript/hooks/useCast.d.ts +6 -1
- package/lib/typescript/hooks/useCast.d.ts.map +1 -1
- package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
- package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
- package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +4 -4
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/queueDelta.d.ts +10 -0
- package/lib/typescript/queueDelta.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +168 -12
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
- package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
- package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
- package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
- package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
- package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
- package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
- package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
- package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
- package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
- package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
- package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
- package/package.json +8 -8
- package/src/TrackPlayer.nitro.ts +53 -27
- package/src/hooks/useActiveTrack.ts +29 -22
- package/src/hooks/useCast.ts +14 -15
- package/src/hooks/useEqualizer.ts +19 -12
- package/src/hooks/useLookaheadCache.ts +3 -7
- package/src/hooks/useQueue.ts +66 -17
- package/src/index.ts +13 -3
- package/src/queueDelta.ts +41 -0
- package/src/types.ts +169 -12
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
- package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
- package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
- package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
- package/ios/MetadataReader.swift +0 -555
- package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
- package/ios/Tests/MetadataReaderTests.swift +0 -603
- package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
- package/ios/Tests/VoiceDonationTests.swift +0 -26
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
- /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
|
@@ -16,17 +16,20 @@ import Foundation
|
|
|
16
16
|
/// `TrackPlayer` is the canonical position; the engine
|
|
17
17
|
/// advances the standby leg's currentItem to the next queue
|
|
18
18
|
/// position ahead of fade-start.
|
|
19
|
-
/// - Equal-power curve approximated via N short
|
|
20
|
-
///
|
|
21
|
-
///
|
|
19
|
+
/// - Equal-power curve approximated via N short `setVolumeRamp`
|
|
20
|
+
/// sub-ramps. AVFoundation does not document the ramp's curve, so
|
|
21
|
+
/// each sub-ramp is treated as linear; ~50 sub-ramps over a fade window
|
|
22
22
|
/// is auditorily indistinguishable from true `cos(t·π/2)` /
|
|
23
23
|
/// `sin(t·π/2)`.
|
|
24
24
|
/// - Standby preroll via `AVPlayer.preroll(atRate: 1.0)` ~500ms
|
|
25
25
|
/// before fade-start so the audio output graph is hot when
|
|
26
26
|
/// its volume ramp begins.
|
|
27
|
-
/// -
|
|
28
|
-
/// mix
|
|
29
|
-
/// recovery is `audioMix = nil` + `volume = userVolume`.
|
|
27
|
+
/// - Workaround: a backward seek through a scheduled `setVolumeRamp`
|
|
28
|
+
/// window leaves the mix in place and the volume wrong on the next
|
|
29
|
+
/// play-through; recovery is `audioMix = nil` + `volume = userVolume`.
|
|
30
|
+
/// TODO: monitor upstream for fixes —
|
|
31
|
+
/// https://wadetregaskis.com/fading-audio-with-avplayer/ documents the
|
|
32
|
+
/// symptom; there is no Apple Feedback number to track.
|
|
30
33
|
/// - Per-boundary route check via `OutputRouteMonitor`: if the
|
|
31
34
|
/// active route is `.airPlay` / `.carAudio`, the boundary
|
|
32
35
|
/// hard-cuts (no fade) for that transition. Mid-fade route
|
|
@@ -42,8 +45,8 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
42
45
|
|
|
43
46
|
/// User-set fade duration captured at construction time. Both
|
|
44
47
|
/// `AVAudioMix` ramp scheduling and the role-swap timer
|
|
45
|
-
/// coordinate against this value; runtime duration changes
|
|
46
|
-
///
|
|
48
|
+
/// coordinate against this value; runtime duration changes go
|
|
49
|
+
/// through `setPlaybackMode`, which constructs a new engine.
|
|
47
50
|
private let crossfadeDurationSeconds: TimeInterval
|
|
48
51
|
|
|
49
52
|
/// Pre-fade preroll lead so the standby leg's audio output
|
|
@@ -51,14 +54,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
51
54
|
/// platform research recommendation.
|
|
52
55
|
static let prerollLeadSeconds: TimeInterval = 0.5
|
|
53
56
|
|
|
57
|
+
/// Backstop for the part of the arm chain the status wait does not cover:
|
|
58
|
+
/// the incoming asset's `tracks` load and preroll, which begin once the
|
|
59
|
+
/// standby leg reports ready and have no bound of their own.
|
|
60
|
+
///
|
|
61
|
+
/// Measured from that flip rather than from the arm, because an arm fires at
|
|
62
|
+
/// most `maxCrossfadeMs + prerollLeadSeconds` before the leading track ends —
|
|
63
|
+
/// a deadline measured from the arm and longer than that could only ever
|
|
64
|
+
/// expire after the boundary it exists to protect.
|
|
65
|
+
static let armDeadlineSeconds: TimeInterval = 5.0
|
|
66
|
+
|
|
54
67
|
/// Equal-power approximation step count. ~50 sub-ramps over a
|
|
55
68
|
/// 6-second fade is below the audibility threshold for the
|
|
56
69
|
/// difference between staircased and continuous cos/sin.
|
|
57
70
|
static let subRampCount: Int = 50
|
|
58
71
|
|
|
59
72
|
/// Volume multiplier applied to both legs during the fade.
|
|
60
|
-
/// Tracked so manual `setVolume(v)` survives a
|
|
61
|
-
///
|
|
73
|
+
/// Tracked so manual `setVolume(v)` survives a fade cancel or the
|
|
74
|
+
/// role swap at fade end.
|
|
62
75
|
private var userVolume: Float = 1.0
|
|
63
76
|
|
|
64
77
|
/// Tracked so the user's playback speed survives a crossfade
|
|
@@ -99,6 +112,20 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
99
112
|
|
|
100
113
|
// MARK: - Queue model
|
|
101
114
|
|
|
115
|
+
/// The queue the owner serialises player access on. Every callback this
|
|
116
|
+
/// engine delivers, and every timer and observer it installs, lands here —
|
|
117
|
+
/// the fade state below carries no lock, so it is only safe while one queue
|
|
118
|
+
/// owns both it and the transport calls driving it.
|
|
119
|
+
private let ownerQueue: DispatchQueue
|
|
120
|
+
|
|
121
|
+
/// Run `body` on the owner's queue and await its result, for the points
|
|
122
|
+
/// where an async fade step has to touch that state again.
|
|
123
|
+
private func onOwnerQueue<T>(_ body: @escaping () -> T) async -> T {
|
|
124
|
+
await withCheckedContinuation { (cont: CheckedContinuation<T, Never>) in
|
|
125
|
+
self.ownerQueue.async { cont.resume(returning: body()) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
/// Engine-internal queue snapshot — caller sets via
|
|
103
130
|
/// `setItems`; engine advances `currentIndex` on user skip /
|
|
104
131
|
/// auto-advance. Each `AVPlayer` instance holds at most one
|
|
@@ -121,7 +148,9 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
121
148
|
/// `NSInvalidArgumentException` when called on a different
|
|
122
149
|
/// instance than the one that issued the token, so the player
|
|
123
150
|
/// is captured alongside the token. Removed on pause / stop /
|
|
124
|
-
///
|
|
151
|
+
/// release / removeAllItems, and by setItems / remove when they drain
|
|
152
|
+
/// the engine; re-installed on play; stopped and restarted across
|
|
153
|
+
/// `completeCrossfade`.
|
|
125
154
|
private var armingObserver: (player: AVPlayer, token: Any)?
|
|
126
155
|
|
|
127
156
|
/// `true` after [armCrossfade] has prerolled standby for the
|
|
@@ -134,21 +163,11 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
134
163
|
/// against a fresh fade.
|
|
135
164
|
private var fadeCompletionTask: Task<Void, Never>?
|
|
136
165
|
|
|
137
|
-
/// Audio-mix provider
|
|
138
|
-
/// `
|
|
139
|
-
///
|
|
166
|
+
/// Audio-mix provider set via `setAudioMixProvider` — the
|
|
167
|
+
/// `AudioTapProvider` shared by `Equalizer` and `Visualizer`. Each
|
|
168
|
+
/// AVPlayerItem's `AVAudioMix` comes from its `audioMix(for:)`.
|
|
140
169
|
private var mixProvider: AudioMixProvider?
|
|
141
170
|
|
|
142
|
-
/// Resolves a track's source URL to its lookahead-cache local file
|
|
143
|
-
/// URL when fully cached, else nil. Supplied by `TrackPlayer` (reads
|
|
144
|
-
/// `LookaheadCache.cachedURL(forUrl:)` lazily so it always sees the
|
|
145
|
-
/// current cache). The engine re-resolves at every late item attach
|
|
146
|
-
/// (skip cross-attach, fade-arm preroll) so a track that finished
|
|
147
|
-
/// caching AFTER its queue item was first built from the remote URL
|
|
148
|
-
/// is played from the local file — instead of re-loading the remote
|
|
149
|
-
/// asset, whose `.tracks` round-trip can fail the now-playing format
|
|
150
|
-
/// extractor.
|
|
151
|
-
private var cachedURLResolver: ((String) -> URL?)?
|
|
152
171
|
|
|
153
172
|
/// Released-once flag so `release()` is idempotent.
|
|
154
173
|
private var released: Bool = false
|
|
@@ -169,10 +188,27 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
169
188
|
/// the leading-leg error path.
|
|
170
189
|
private var standbyReadyTimeout: DispatchWorkItem?
|
|
171
190
|
|
|
191
|
+
/// Deadline on the whole arm chain, not just the wait for the standby leg's
|
|
192
|
+
/// status.
|
|
193
|
+
///
|
|
194
|
+
/// `standbyReadyTimeout` is cancelled the moment the status flips, and the
|
|
195
|
+
/// work after that — loading the incoming asset's `tracks` key — has no
|
|
196
|
+
/// bound of its own. Without this deadline a remote asset that never
|
|
197
|
+
/// resolves leaves `standbyArmed` set, and the engine work deferred on
|
|
198
|
+
/// that flag never flushes.
|
|
199
|
+
private var armDeadline: DispatchWorkItem?
|
|
200
|
+
|
|
201
|
+
/// Whether volume ramps have actually been written onto either leg's mix
|
|
202
|
+
/// parameters. Only a written ramp can leave the mix in the state
|
|
203
|
+
/// `cancelFadeInternal`'s reset recovers from, and being armed is not the
|
|
204
|
+
/// same thing: the chain can be armed and still waiting for the incoming
|
|
205
|
+
/// asset, with nothing written yet.
|
|
206
|
+
private var rampsScheduled = false
|
|
207
|
+
|
|
172
208
|
/// Generation counter incremented at every [awaitStandbyReady]
|
|
173
209
|
/// entry. Closures captured by the KVO observer + the timeout
|
|
174
210
|
/// `DispatchWorkItem` carry their generation value — if a new
|
|
175
|
-
/// arm cycle increments the counter while a stale
|
|
211
|
+
/// arm cycle increments the counter while a stale `ownerQueue`
|
|
176
212
|
/// hop is pending, the gate inside the closure short-circuits.
|
|
177
213
|
/// Defends against a narrow race where the original arm's
|
|
178
214
|
/// `.readyToPlay` callback fires AFTER `cancelFadeInternal` +
|
|
@@ -183,7 +219,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
183
219
|
|
|
184
220
|
/// `\.timeControlStatus` KVO on the leading leg + the player it
|
|
185
221
|
/// was registered against. Re-installed on every role swap.
|
|
186
|
-
/// Fires `engineStateMaybeChanged
|
|
222
|
+
/// Fires `engineStateMaybeChanged`.
|
|
187
223
|
private var leadingTimeControlObserver: NSKeyValueObservation?
|
|
188
224
|
|
|
189
225
|
/// `\.currentItem.tracks` KVO on the leading leg's currentItem.
|
|
@@ -195,6 +231,15 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
195
231
|
/// Routes `.failed` through `engineDidFailWithError`.
|
|
196
232
|
private var leadingItemStatusObserver: NSKeyValueObservation?
|
|
197
233
|
|
|
234
|
+
/// `\.isPlaybackBufferEmpty` / `\.isPlaybackLikelyToKeepUp` /
|
|
235
|
+
/// `\.isPlaybackBufferFull` KVO on the leading leg's currentItem. Each fires
|
|
236
|
+
/// `engineBufferStateMaybeChanged` so a fast local reload's empty→full edge
|
|
237
|
+
/// reaches the discrete buffer-state surface — the gapless engine gets the
|
|
238
|
+
/// same from its own item KVO. Re-installed on role swap and on `setItems`.
|
|
239
|
+
private var leadingItemBufferEmptyObserver: NSKeyValueObservation?
|
|
240
|
+
private var leadingItemBufferKeepUpObserver: NSKeyValueObservation?
|
|
241
|
+
private var leadingItemBufferFullObserver: NSKeyValueObservation?
|
|
242
|
+
|
|
198
243
|
/// Block-based `NotificationCenter` observer tokens for the
|
|
199
244
|
/// AVPlayerItem lifecycle notifications. Filtered inside the
|
|
200
245
|
/// block to leading-leg only. Removed in `release`.
|
|
@@ -203,12 +248,13 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
203
248
|
|
|
204
249
|
// MARK: - Init
|
|
205
250
|
|
|
206
|
-
init(crossfadeDurationSeconds: TimeInterval) {
|
|
251
|
+
init(crossfadeDurationSeconds: TimeInterval, confinedTo ownerQueue: DispatchQueue) {
|
|
207
252
|
precondition(
|
|
208
253
|
crossfadeDurationSeconds >= 0,
|
|
209
254
|
"crossfadeDurationSeconds must be non-negative"
|
|
210
255
|
)
|
|
211
256
|
self.crossfadeDurationSeconds = crossfadeDurationSeconds
|
|
257
|
+
self.ownerQueue = ownerQueue
|
|
212
258
|
|
|
213
259
|
let p1 = AVPlayer()
|
|
214
260
|
p1.automaticallyWaitsToMinimizeStalling = true
|
|
@@ -228,9 +274,8 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
228
274
|
// doesn't arbitrarily silence one of the two players. The
|
|
229
275
|
// outgoing leg snaps to userVolume; the standby drops its
|
|
230
276
|
// item.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
277
|
+
guard let self else { return }
|
|
278
|
+
self.ownerQueue.async { self.cancelFade() }
|
|
234
279
|
}
|
|
235
280
|
|
|
236
281
|
// Lib-wide notifications fire for any AVPlayerItem; the
|
|
@@ -239,46 +284,27 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
239
284
|
itemDidPlayToEndToken = NotificationCenter.default.addObserver(
|
|
240
285
|
forName: .AVPlayerItemDidPlayToEndTime,
|
|
241
286
|
object: nil,
|
|
242
|
-
|
|
287
|
+
// `nil` delivers on the posting thread — AVFoundation's, not ours — so
|
|
288
|
+
// the hop onto the owner's queue is explicit below.
|
|
289
|
+
queue: nil
|
|
243
290
|
) { [weak self] note in
|
|
244
|
-
self
|
|
291
|
+
guard let self else { return }
|
|
292
|
+
self.ownerQueue.async { self.handleItemDidPlayToEnd(note) }
|
|
245
293
|
}
|
|
246
294
|
itemFailedToPlayToEndToken = NotificationCenter.default.addObserver(
|
|
247
295
|
forName: .AVPlayerItemFailedToPlayToEndTime,
|
|
248
296
|
object: nil,
|
|
249
|
-
|
|
297
|
+
// `nil` delivers on the posting thread — AVFoundation's, not ours — so
|
|
298
|
+
// the hop onto the owner's queue is explicit below.
|
|
299
|
+
queue: nil
|
|
250
300
|
) { [weak self] note in
|
|
251
|
-
self
|
|
301
|
+
guard let self else { return }
|
|
302
|
+
self.ownerQueue.async { self.handleItemFailedToPlayToEnd(note) }
|
|
252
303
|
}
|
|
253
|
-
|
|
254
|
-
// iOS 26+ AVRoutingPlaybackArbiter would let the engine name
|
|
255
|
-
// the preferred participant for non-mixable routes (AirPlay
|
|
256
|
-
// 2 / wireless CarPlay) so iOS doesn't arbitrarily silence
|
|
257
|
-
// one of two playing AVPlayer instances during the
|
|
258
|
-
// engagement transient. The current iOS SDK in this build
|
|
259
|
-
// ships AVRoutingPlaybackArbiter but the
|
|
260
|
-
// `preferredParticipantForNonMixableAudioRoutes` property
|
|
261
|
-
// marker is unavailable on iOS at the SDK header level —
|
|
262
|
-
// tracked as a follow-up once the SDK exposes it on iOS or
|
|
263
|
-
// an alternative API surface lands.
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/// No-op placeholder — see init comment for the iOS 26+
|
|
267
|
-
/// AVRoutingPlaybackArbiter follow-up.
|
|
268
|
-
private func updateRoutingArbiter() {
|
|
269
|
-
// Intentional no-op pending SDK availability.
|
|
270
304
|
}
|
|
271
305
|
|
|
272
306
|
// MARK: - Lifecycle
|
|
273
307
|
|
|
274
|
-
func prepare(
|
|
275
|
-
items: [AVPlayerItem],
|
|
276
|
-
startIndex: Int,
|
|
277
|
-
startPositionSeconds: TimeInterval
|
|
278
|
-
) async {
|
|
279
|
-
setItems(items, startIndex: startIndex, startPositionSeconds: startPositionSeconds)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
308
|
func release() {
|
|
283
309
|
guard !released else { return }
|
|
284
310
|
released = true
|
|
@@ -299,6 +325,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
299
325
|
itemFailedToPlayToEndToken = nil
|
|
300
326
|
}
|
|
301
327
|
routeMonitor.stop()
|
|
328
|
+
AVQueueBuilder.cancelLoading(dropping: queue)
|
|
302
329
|
leadingPlayer.replaceCurrentItem(with: nil)
|
|
303
330
|
standbyPlayer.replaceCurrentItem(with: nil)
|
|
304
331
|
queue.removeAll()
|
|
@@ -314,11 +341,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
314
341
|
startIndex: Int,
|
|
315
342
|
startPositionSeconds: TimeInterval
|
|
316
343
|
) {
|
|
344
|
+
// `replaceCurrentItem` below drops the leading leg to rate 0, so a
|
|
345
|
+
// rebuild issued mid-playback would silently pause. Capture the intent
|
|
346
|
+
// and restore the rate after the new item is seated — the same contract
|
|
347
|
+
// `GaplessEngine.setItems` upholds across its own rebuild. The run
|
|
348
|
+
// resumes exactly when a `play()` is still standing over it.
|
|
349
|
+
let shouldResume = wantsPlayback
|
|
317
350
|
cancelFadeInternal()
|
|
351
|
+
// The outgoing slice is discarded here. Anything the incoming set reuses
|
|
352
|
+
// keeps its loads — a cancelled asset never becomes playable, and this
|
|
353
|
+
// engine deliberately rebuilds items on reused assets at seek, remove
|
|
354
|
+
// and fade-arm.
|
|
355
|
+
AVQueueBuilder.cancelLoading(dropping: queue, keeping: items)
|
|
318
356
|
queue = items
|
|
319
357
|
teardownLeadingObservers()
|
|
320
358
|
if items.isEmpty || startIndex < 0 || startIndex >= items.count {
|
|
321
359
|
currentIndex = -1
|
|
360
|
+
stopArmingObserver()
|
|
361
|
+
leadingPlayer.pause()
|
|
322
362
|
leadingPlayer.replaceCurrentItem(with: nil)
|
|
323
363
|
standbyPlayer.replaceCurrentItem(with: nil)
|
|
324
364
|
return
|
|
@@ -335,8 +375,10 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
335
375
|
completionHandler: nil
|
|
336
376
|
)
|
|
337
377
|
}
|
|
378
|
+
if shouldResume {
|
|
379
|
+
resumeLeadingRespectingTap()
|
|
380
|
+
}
|
|
338
381
|
installLeadingObservers()
|
|
339
|
-
updateRoutingArbiter()
|
|
340
382
|
delegate?.engineDidTransitionTrack(self)
|
|
341
383
|
}
|
|
342
384
|
|
|
@@ -376,12 +418,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
376
418
|
currentIndex = queue.count - 1
|
|
377
419
|
}
|
|
378
420
|
if currentIndex < 0 {
|
|
421
|
+
stopArmingObserver()
|
|
422
|
+
leadingPlayer.pause()
|
|
379
423
|
leadingPlayer.replaceCurrentItem(with: nil)
|
|
380
424
|
} else {
|
|
381
|
-
let
|
|
425
|
+
let shouldResume = wantsPlayback
|
|
426
|
+
teardownLeadingObservers()
|
|
427
|
+
// Never the stored instance: the slot that shifts in here may be the
|
|
428
|
+
// item the arm attached to the standby leg, and attaching an item
|
|
429
|
+
// still associated with another player raises an exception Swift
|
|
430
|
+
// cannot catch.
|
|
431
|
+
let next = rebuiltItem(at: currentIndex)
|
|
382
432
|
applyMixIfNeeded(to: next)
|
|
383
433
|
applyPitchAlgorithm(to: next)
|
|
384
434
|
leadingPlayer.replaceCurrentItem(with: next)
|
|
435
|
+
if shouldResume {
|
|
436
|
+
resumeLeadingRespectingTap()
|
|
437
|
+
}
|
|
438
|
+
installLeadingObservers()
|
|
385
439
|
}
|
|
386
440
|
} else if idx < currentIndex {
|
|
387
441
|
currentIndex -= 1
|
|
@@ -389,7 +443,15 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
389
443
|
}
|
|
390
444
|
|
|
391
445
|
func removeAllItems() {
|
|
446
|
+
// Nothing left to want: an intent left standing here resumes the next run
|
|
447
|
+
// installed rather than waiting to be asked.
|
|
448
|
+
stopArmingObserver()
|
|
449
|
+
// Detaching the item does not stop the player — `replaceCurrentItem(with:
|
|
450
|
+
// nil)` leaves the rate where it was, so the next item seated on this leg
|
|
451
|
+
// would start on contact. Draining parks it.
|
|
452
|
+
leadingPlayer.pause()
|
|
392
453
|
cancelFadeInternal()
|
|
454
|
+
AVQueueBuilder.cancelLoading(dropping: queue)
|
|
393
455
|
queue.removeAll()
|
|
394
456
|
currentIndex = -1
|
|
395
457
|
leadingPlayer.replaceCurrentItem(with: nil)
|
|
@@ -406,9 +468,12 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
406
468
|
// MARK: - Transport
|
|
407
469
|
|
|
408
470
|
func play() {
|
|
409
|
-
//
|
|
410
|
-
|
|
471
|
+
// Start the arming observer first so `wantsPlayback` is true before the
|
|
472
|
+
// leading `.readyToPlay` observer might resume a leg held for its tap.
|
|
473
|
+
// `resumeLeadingRespectingTap` restores the user's speed (AVPlayer.play()
|
|
474
|
+
// would reset rate to 1.0).
|
|
411
475
|
startArmingObserver()
|
|
476
|
+
resumeLeadingRespectingTap()
|
|
412
477
|
}
|
|
413
478
|
|
|
414
479
|
func pause() {
|
|
@@ -435,16 +500,16 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
435
500
|
|
|
436
501
|
func seek(toIndex index: Int, position: TimeInterval) {
|
|
437
502
|
guard index >= 0, index < queue.count else { return }
|
|
438
|
-
//
|
|
503
|
+
// Read playing intent before the fade-cancel: a cross-attach
|
|
439
504
|
// (replaceCurrentItem) drops the leading player to rate 0, so the
|
|
440
505
|
// new leg must be resumed at the user's speed below to match the
|
|
441
506
|
// gapless skip (which restores rate across its queue rebuild).
|
|
442
|
-
let
|
|
507
|
+
let shouldResume = wantsPlayback
|
|
443
508
|
// Seek is itself a track-change: the engineDidTransitionTrack
|
|
444
509
|
// fire at the end of this method is the authoritative active-
|
|
445
510
|
// track flip for the new index. Suppress the cancel-revert
|
|
446
511
|
// emit so a mid-fade seek doesn't dispatch an intermediate
|
|
447
|
-
// engineCrossfadeDidCancel(
|
|
512
|
+
// `engineCrossfadeDidCancel` (a revert to the outgoing leg)
|
|
448
513
|
// before the seek's own track-change lands.
|
|
449
514
|
cancelFadeInternal(emitRevert: false)
|
|
450
515
|
teardownLeadingObservers()
|
|
@@ -466,9 +531,13 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
466
531
|
// zero to honour the seek contract.
|
|
467
532
|
existing.seek(to: .zero, completionHandler: nil)
|
|
468
533
|
}
|
|
534
|
+
// No re-attach here, but an item that reached its end has parked its leg
|
|
535
|
+
// at rate 0, and seeking back into it must play from where it lands.
|
|
536
|
+
if shouldResume {
|
|
537
|
+
resumeLeadingRespectingTap()
|
|
538
|
+
}
|
|
469
539
|
standbyArmed = false
|
|
470
540
|
installLeadingObservers()
|
|
471
|
-
updateRoutingArbiter()
|
|
472
541
|
delegate?.engineDidTransitionTrack(self)
|
|
473
542
|
return
|
|
474
543
|
}
|
|
@@ -486,20 +555,17 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
486
555
|
// queueItemId across so TrackPlayer.matchTrackIndex can still
|
|
487
556
|
// resolve the new item back to its queue position.
|
|
488
557
|
let target = AVPlayerItem(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
// otherwise this fresh item re-loads the remote asset and the
|
|
492
|
-
// `.tracks` round-trip can fail the now-playing format extractor.
|
|
493
|
-
asset: cacheResolvedAsset(for: existing),
|
|
494
|
-
// Mirror AVQueueBuilder.makeItem fully — including "tracks". A forward
|
|
495
|
-
// skip to a cached track rebuilds the incoming leg here; without the
|
|
496
|
-
// tracks key auto-loading, the `.new`-only tracks KVO never fires for
|
|
497
|
-
// the fresh item and the now-playing format never resolves.
|
|
498
|
-
automaticallyLoadedAssetKeys: ["playable", "duration", "tracks"]
|
|
558
|
+
asset: existing.asset,
|
|
559
|
+
automaticallyLoadedAssetKeys: ["playable", "duration"]
|
|
499
560
|
)
|
|
500
561
|
target.preferredForwardBufferDuration = 0.0
|
|
501
562
|
target.queueItemId = existing.queueItemId
|
|
502
563
|
target.sourceURL = existing.sourceURL
|
|
564
|
+
target.suppliedReplayGain = existing.suppliedReplayGain
|
|
565
|
+
// The routing stamp lives on the item instance (associated object), not the
|
|
566
|
+
// reused asset, so carry it too — without it source classification reads the
|
|
567
|
+
// default `false` and reports a cache-served track as `.streaming`.
|
|
568
|
+
target.isRoutedThroughReadThroughServer = existing.isRoutedThroughReadThroughServer
|
|
503
569
|
// Replace the queue slot with the rebuilt item — any cached
|
|
504
570
|
// AVPlayerItem reference held by a future callsite (e.g. an
|
|
505
571
|
// armCrossfade re-arming on this index) must read queue[index]
|
|
@@ -515,15 +581,14 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
515
581
|
completionHandler: nil
|
|
516
582
|
)
|
|
517
583
|
}
|
|
518
|
-
if
|
|
519
|
-
// replaceCurrentItem leaves the player at rate 0 — resume
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
584
|
+
if shouldResume {
|
|
585
|
+
// replaceCurrentItem leaves the player at rate 0 — resume respecting a
|
|
586
|
+
// wanted tap (mirrors play() / seek) so a skip while playing at a
|
|
587
|
+
// non-default speed doesn't fall back to 1.0x and the tap survives.
|
|
588
|
+
resumeLeadingRespectingTap()
|
|
523
589
|
}
|
|
524
590
|
standbyArmed = false
|
|
525
591
|
installLeadingObservers()
|
|
526
|
-
updateRoutingArbiter()
|
|
527
592
|
delegate?.engineDidTransitionTrack(self)
|
|
528
593
|
}
|
|
529
594
|
|
|
@@ -610,9 +675,12 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
610
675
|
}
|
|
611
676
|
|
|
612
677
|
var isPlaying: Bool {
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
678
|
+
// Whether the outgoing leg is audible right now — not whether the engine
|
|
679
|
+
// was asked to play. It reads false mid-fade once the leading leg's item
|
|
680
|
+
// has ended, and false while a leg is buffering, so a caller deciding
|
|
681
|
+
// whether to restore a rate wants `wantsPlayback` instead. The
|
|
682
|
+
// `timeControlStatus` accessor below reads the active leg (standby while
|
|
683
|
+
// fading) to match `currentMediaItem` for state derivation.
|
|
616
684
|
leadingPlayer.timeControlStatus == .playing
|
|
617
685
|
}
|
|
618
686
|
|
|
@@ -629,6 +697,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
629
697
|
return player.currentItem
|
|
630
698
|
}
|
|
631
699
|
|
|
700
|
+
/// The outgoing leg's item for the whole fade — `currentMediaItem` switches
|
|
701
|
+
/// to the incoming leg as soon as the fade starts, which is the wrong end to
|
|
702
|
+
/// anchor an installed run on.
|
|
703
|
+
var leadingQueueItemId: String? {
|
|
704
|
+
leadingPlayer.currentItem?.queueItemId
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
var isFadePendingOrActive: Bool { standbyArmed || fadeInFlight }
|
|
708
|
+
|
|
709
|
+
/// Both legs while a fade is scheduled or running: the standby leg carries a
|
|
710
|
+
/// prerolled item that is about to be heard, and a mutation that removes it
|
|
711
|
+
/// out from under the arm chain strands the fade on an item the queue no
|
|
712
|
+
/// longer has.
|
|
713
|
+
var attachedQueueItemIds: [String] {
|
|
714
|
+
[leadingPlayer.currentItem?.queueItemId, standbyPlayer.currentItem?.queueItemId]
|
|
715
|
+
.compactMap { $0 }
|
|
716
|
+
}
|
|
717
|
+
|
|
632
718
|
var allMediaItems: [AVPlayerItem] {
|
|
633
719
|
queue
|
|
634
720
|
}
|
|
@@ -640,64 +726,29 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
640
726
|
refreshAllItemMixes()
|
|
641
727
|
}
|
|
642
728
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
///
|
|
648
|
-
///
|
|
649
|
-
///
|
|
650
|
-
///
|
|
651
|
-
|
|
652
|
-
private func cachedLocalURL(for item: AVPlayerItem) -> URL? {
|
|
653
|
-
guard let resolver = cachedURLResolver, let url = item.sourceURL else {
|
|
654
|
-
return nil
|
|
655
|
-
}
|
|
656
|
-
return resolver(url)
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/// The asset a queue item should be (re)built from at a late attach:
|
|
660
|
-
/// the lookahead cache's local file when [existing]'s track has
|
|
661
|
-
/// since cached, else [existing]'s own asset. Re-resolving here lets
|
|
662
|
-
/// a forward/back skip to a now-cached track play from local disk
|
|
663
|
-
/// rather than re-loading the remote asset.
|
|
664
|
-
private func cacheResolvedAsset(for existing: AVPlayerItem) -> AVAsset {
|
|
665
|
-
// Reuse the existing asset when it's already this cache file (built
|
|
666
|
-
// cached at queue-build time) — the cross-attach rebuilds the item
|
|
667
|
-
// regardless, so there's nothing to upgrade and a fresh AVURLAsset
|
|
668
|
-
// on the same file would just be a wasted allocation.
|
|
669
|
-
guard let cachedURL = cachedLocalURL(for: existing),
|
|
670
|
-
(existing.asset as? AVURLAsset)?.url != cachedURL else {
|
|
671
|
-
return existing.asset
|
|
672
|
-
}
|
|
673
|
-
return AVURLAsset(url: cachedURL)
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/// If [index]'s queue item was built from a remote URL whose track
|
|
677
|
-
/// has since fully cached, swap the slot for a fresh item backed by
|
|
678
|
-
/// the local cache file (carrying `queueItemId` + `sourceURL` across)
|
|
679
|
-
/// and return it; otherwise return the existing item unchanged. Lets
|
|
680
|
-
/// the fade-arm preroll pick up a track that cached after the queue
|
|
681
|
-
/// was first built so an auto-advance crossfade plays from local disk.
|
|
682
|
-
private func cacheUpgradedItem(at index: Int) -> AVPlayerItem {
|
|
729
|
+
/// Resolve the item for [index] as a **fresh** `AVPlayerItem`.
|
|
730
|
+
///
|
|
731
|
+
/// Never hands back the stored instance. Attaching an `AVPlayerItem` that is
|
|
732
|
+
/// still associated with another `AVPlayer` raises an `NSException` that
|
|
733
|
+
/// Swift cannot catch, and the association is released asynchronously with
|
|
734
|
+
/// no API to observe it — so an item that was on a leg a moment ago is not
|
|
735
|
+
/// safe to attach, and there is no way to ask whether it is. `seek(toIndex:)`
|
|
736
|
+
/// rebuilds for the same reason.
|
|
737
|
+
private func rebuiltItem(at index: Int) -> AVPlayerItem {
|
|
683
738
|
let existing = queue[index]
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
// identical rebuild + re-preroll.
|
|
688
|
-
guard let cachedURL = cachedLocalURL(for: existing),
|
|
689
|
-
(existing.asset as? AVURLAsset)?.url != cachedURL else {
|
|
690
|
-
return existing
|
|
691
|
-
}
|
|
692
|
-
let upgraded = AVPlayerItem(
|
|
693
|
-
asset: AVURLAsset(url: cachedURL),
|
|
694
|
-
automaticallyLoadedAssetKeys: ["playable", "duration", "tracks"]
|
|
739
|
+
let rebuilt = AVPlayerItem(
|
|
740
|
+
asset: existing.asset,
|
|
741
|
+
automaticallyLoadedAssetKeys: ["playable", "duration"]
|
|
695
742
|
)
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
743
|
+
rebuilt.preferredForwardBufferDuration = 0.0
|
|
744
|
+
rebuilt.queueItemId = existing.queueItemId
|
|
745
|
+
rebuilt.sourceURL = existing.sourceURL
|
|
746
|
+
rebuilt.suppliedReplayGain = existing.suppliedReplayGain
|
|
747
|
+
// Carry the read-through routing stamp (an item-instance associated object)
|
|
748
|
+
// so source classification keeps reporting `.cached` across the rebuild.
|
|
749
|
+
rebuilt.isRoutedThroughReadThroughServer = existing.isRoutedThroughReadThroughServer
|
|
750
|
+
queue[index] = rebuilt
|
|
751
|
+
return rebuilt
|
|
701
752
|
}
|
|
702
753
|
|
|
703
754
|
func refreshAllItemMixes() {
|
|
@@ -713,6 +764,10 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
713
764
|
if let standby = standbyPlayer.currentItem, standby.audioMix == nil {
|
|
714
765
|
standby.audioMix = mixProvider?.audioMix(for: standby)
|
|
715
766
|
}
|
|
767
|
+
// `audioMix(for:)` reports the asset it built for, so walking the standby
|
|
768
|
+
// leg last leaves the *upcoming* track standing as the active asset. Say
|
|
769
|
+
// which one is actually audible.
|
|
770
|
+
mixProvider?.noteActiveAsset(leadingPlayer.currentItem?.asset)
|
|
716
771
|
}
|
|
717
772
|
|
|
718
773
|
private func applyMixIfNeeded(to item: AVPlayerItem) {
|
|
@@ -721,6 +776,100 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
721
776
|
item.audioMix = mix
|
|
722
777
|
}
|
|
723
778
|
|
|
779
|
+
/// Resume the leading leg for a fresh seat / resume, holding it at rate 0
|
|
780
|
+
/// when a consumer wants the tap and the item is not yet ready to render.
|
|
781
|
+
///
|
|
782
|
+
/// AVPlayer honours `audioMix` only before an item renders; a mix set once
|
|
783
|
+
/// the leg is playing is silently dropped and the equaliser / visualizer tap
|
|
784
|
+
/// never prepares. When a tap is wanted we keep a not-yet-ready leg at rate 0
|
|
785
|
+
/// and let the leading `.readyToPlay` observer load tracks, install the mix,
|
|
786
|
+
/// then resume the held leg (see `installLeadingObservers` and
|
|
787
|
+
/// `installLeadingTapMixThenResume`). When the item is already `.readyToPlay`
|
|
788
|
+
/// the `.new`-only status observer will not fire, so drive that same path
|
|
789
|
+
/// here. The common no-tap path — and any provider without a consumer
|
|
790
|
+
/// model — resumes immediately, unchanged.
|
|
791
|
+
///
|
|
792
|
+
/// Callers hold a live playback intent. Used for fresh seats and `play()`,
|
|
793
|
+
/// and for the same-instance seek so it cannot start a leg a preceding seat
|
|
794
|
+
/// is holding. NOT used by `completeCrossfade` (it promotes an
|
|
795
|
+
/// already-rendering leg — holding would pause live audio) or the repeat-one
|
|
796
|
+
/// loop (the same already-ready item).
|
|
797
|
+
private func resumeLeadingRespectingTap() {
|
|
798
|
+
let player = leadingPlayer
|
|
799
|
+
guard let item = player.currentItem else {
|
|
800
|
+
player.rate = userPlaybackSpeed
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
guard mixProvider?.anyConsumerWantsTap() == true else {
|
|
804
|
+
player.rate = userPlaybackSpeed
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
// A deterministically-missing local file never reaches `.readyToPlay`, and
|
|
808
|
+
// `installLeadingObservers` surfaces its failure without installing a
|
|
809
|
+
// status observer — so nothing would resume a held leg. Resume directly:
|
|
810
|
+
// it cannot render a tap anyway, and the failure path drives the retry.
|
|
811
|
+
if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
|
|
812
|
+
!FileManager.default.fileExists(atPath: asset.url.path) {
|
|
813
|
+
player.rate = userPlaybackSpeed
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
if item.status == .readyToPlay {
|
|
817
|
+
// Already ready: the `.new`-only status observer will not fire, so drive
|
|
818
|
+
// the tracks-load → mix-install → resume here. The leg is still held at
|
|
819
|
+
// rate 0 (not rendering) until that completes.
|
|
820
|
+
installLeadingTapMixThenResume(item: item)
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
// Not yet ready: leave the leg at rate 0. `installLeadingObservers` arms
|
|
824
|
+
// `autoWaits = true` for a not-yet-ready item and its `.readyToPlay`
|
|
825
|
+
// observer installs the mix and then resumes this held leg.
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/// Load the leading item's `tracks` key, then install its tap-mix and resume
|
|
829
|
+
/// a leg held for the tap. `.readyToPlay` does not guarantee the asset's
|
|
830
|
+
/// `tracks` key is loaded — that is a separate async load — and
|
|
831
|
+
/// `audioMix(for:)` yields nil until it is (the reason the arm path loads
|
|
832
|
+
/// tracks before attaching the standby's mix). Loading first guarantees the
|
|
833
|
+
/// mix builds, so the tap is live before the first sample. The leg stays held
|
|
834
|
+
/// (rate 0 under a live play-intent, `autoWaits` true) until the completion
|
|
835
|
+
/// installs the mix, permits render, and resumes it.
|
|
836
|
+
///
|
|
837
|
+
/// The load is bounded by `armDeadlineSeconds`, matching the standby arm
|
|
838
|
+
/// deadline: a stall after `.readyToPlay` must not leave the held leg silent
|
|
839
|
+
/// forever, so on timeout (or a load failure) the leg resumes untapped. The
|
|
840
|
+
/// completion installs the mix only while the leg is still held (rate 0):
|
|
841
|
+
/// once it has resumed, AVPlayer drops a mix set on a rendering item and
|
|
842
|
+
/// leaks its tap cascade (see `refreshAllItemMixes`), so a consumer that
|
|
843
|
+
/// subscribed after an untapped seat is left to the format-changed catch-up.
|
|
844
|
+
/// Re-checks the engine is live and the item is still the captured leg's
|
|
845
|
+
/// current one across the load.
|
|
846
|
+
private func installLeadingTapMixThenResume(item: AVPlayerItem) {
|
|
847
|
+
let player = leadingPlayer
|
|
848
|
+
let asset = item.asset
|
|
849
|
+
Task { [weak self] in
|
|
850
|
+
await withTaskGroup(of: Void.self) { group in
|
|
851
|
+
group.addTask { _ = try? await asset.load(.tracks) }
|
|
852
|
+
group.addTask {
|
|
853
|
+
try? await Task.sleep(
|
|
854
|
+
nanoseconds: UInt64(Self.armDeadlineSeconds * 1_000_000_000))
|
|
855
|
+
}
|
|
856
|
+
_ = await group.next()
|
|
857
|
+
group.cancelAll()
|
|
858
|
+
}
|
|
859
|
+
guard let self else { return }
|
|
860
|
+
await self.onOwnerQueue {
|
|
861
|
+
guard !self.released, player.currentItem === item else { return }
|
|
862
|
+
if player.rate == 0 {
|
|
863
|
+
self.refreshAllItemMixes()
|
|
864
|
+
}
|
|
865
|
+
player.automaticallyWaitsToMinimizeStalling = false
|
|
866
|
+
if self.wantsPlayback, player.rate == 0 {
|
|
867
|
+
player.rate = self.userPlaybackSpeed
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
724
873
|
func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {
|
|
725
874
|
pitchCorrectionMode = mode
|
|
726
875
|
reapplyPitchToCurrentLegs()
|
|
@@ -744,16 +893,6 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
744
893
|
if let standby = standbyPlayer.currentItem { applyPitchAlgorithm(to: standby) }
|
|
745
894
|
}
|
|
746
895
|
|
|
747
|
-
// MARK: - Handoff
|
|
748
|
-
|
|
749
|
-
func handoff(to nextEngine: PlaybackEngine, atTrackBoundary: Bool) async {
|
|
750
|
-
// Engine handoff (gapless ↔ crossfade) is driven by
|
|
751
|
-
// TrackPlayer.swift's mode-switch path which holds the
|
|
752
|
-
// canonical queue model. The engine's own `release()` is
|
|
753
|
-
// the caller's responsibility AFTER the swap.
|
|
754
|
-
return
|
|
755
|
-
}
|
|
756
|
-
|
|
757
896
|
// MARK: - Equal-power gain math
|
|
758
897
|
|
|
759
898
|
/// Equal-power gain pair for fade progress `t ∈ [0, 1]`.
|
|
@@ -821,22 +960,34 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
821
960
|
|
|
822
961
|
// MARK: - Boundary arming
|
|
823
962
|
|
|
963
|
+
/// Whether the transport wants playback, independent of which leg is
|
|
964
|
+
/// producing sound.
|
|
965
|
+
///
|
|
966
|
+
/// The arming observer is the intent: `play()` installs it; `pause()`,
|
|
967
|
+
/// `stop()` and `release()` remove it; every path that drains the engine
|
|
968
|
+
/// removes it, because a run with nothing in it is not a run being played;
|
|
969
|
+
/// and `completeCrossfade` stops and restarts it across the role flip,
|
|
970
|
+
/// synchronously, so no reader sees the gap.
|
|
971
|
+
///
|
|
972
|
+
/// A leg's rate or status cannot answer this. Mid-fade the outgoing leading
|
|
973
|
+
/// leg has already stopped while the incoming one plays, and an item that
|
|
974
|
+
/// has reached its end leaves its leg at rate 0 with the transport still
|
|
975
|
+
/// wanting to play. Restoring the rate from either of those leaves the
|
|
976
|
+
/// engine paused against the consumer's intent.
|
|
977
|
+
private var wantsPlayback: Bool { armingObserver != nil }
|
|
978
|
+
|
|
824
979
|
/// Boundary-arming periodic time observer. Watches the leading
|
|
825
980
|
/// leg's time at 200ms cadence; when within
|
|
826
981
|
/// `effectiveCrossfadeSeconds + prerollLeadSeconds` of track end,
|
|
827
982
|
/// prerolls standby + schedules the equal-power ramp on both
|
|
828
983
|
/// legs' `AVMutableAudioMixInputParameters`.
|
|
829
|
-
///
|
|
830
|
-
/// Cancellation: `pause` / `stop` / `seek` / `skipToNext` /
|
|
831
|
-
/// `skipToPrevious` / `release` all remove the observer. `play`
|
|
832
|
-
/// re-installs it.
|
|
833
984
|
private func startArmingObserver() {
|
|
834
985
|
if armingObserver != nil { return }
|
|
835
986
|
let player = leadingPlayer
|
|
836
987
|
let interval = CMTime(seconds: 0.2, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
837
988
|
let token = player.addPeriodicTimeObserver(
|
|
838
989
|
forInterval: interval,
|
|
839
|
-
queue:
|
|
990
|
+
queue: ownerQueue
|
|
840
991
|
) { [weak self] _ in
|
|
841
992
|
self?.maybeArmCrossfade()
|
|
842
993
|
}
|
|
@@ -860,6 +1011,12 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
860
1011
|
// would advance past the track the user asked to repeat.
|
|
861
1012
|
if repeatMode == .track { return }
|
|
862
1013
|
if currentIndex < 0 || currentIndex >= queue.count - 1 { return } // last track or empty
|
|
1014
|
+
// Only a leg that is rendering arms. A rate written onto a leg sitting at
|
|
1015
|
+
// its end — a play() after the queue ended — reports the end position
|
|
1016
|
+
// without moving, and arming there would fade into whatever was queued
|
|
1017
|
+
// behind the finished track. A leg stalled inside the arm window waits
|
|
1018
|
+
// for the tick on which it resumes, so the standby prerolls from then.
|
|
1019
|
+
if leadingPlayer.timeControlStatus != .playing { return }
|
|
863
1020
|
guard let leading = leadingPlayer.currentItem else { return }
|
|
864
1021
|
let duration = leading.duration
|
|
865
1022
|
if !duration.isNumeric { return }
|
|
@@ -895,22 +1052,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
895
1052
|
/// wait until the status reaches `.readyToPlay` before invoking
|
|
896
1053
|
/// `preroll`; `runFade` chains through the readiness callback so
|
|
897
1054
|
/// the timing semantics (preroll → ramp schedule → wall-clock
|
|
898
|
-
/// completion) are preserved.
|
|
899
|
-
///
|
|
900
|
-
/// resets the item's playhead — and was masking the readiness
|
|
901
|
-
/// race because seek-on-detached-item is a documented no-op on
|
|
902
|
-
/// modern iOS.
|
|
1055
|
+
/// completion) are preserved. No seek before the attach:
|
|
1056
|
+
/// `replaceCurrentItem` resets the item's playhead.
|
|
903
1057
|
private func armCrossfade(durationSeconds: TimeInterval) {
|
|
904
|
-
|
|
905
|
-
|
|
1058
|
+
// Validate the index here rather than trusting the caller's earlier
|
|
1059
|
+
// check: `currentIndex + 1` is a trapping add, so a `currentIndex` of
|
|
1060
|
+
// `Int.max` crashes the process before any bounds check can run.
|
|
1061
|
+
// `NSNotFound` is `Int.max` on 64-bit, so a not-found sentinel that
|
|
1062
|
+
// reaches this field is fatal rather than merely wrong.
|
|
1063
|
+
let idx = currentIndex
|
|
1064
|
+
let count = queue.count
|
|
1065
|
+
guard idx >= 0, idx < count - 1 else {
|
|
906
1066
|
standbyArmed = false
|
|
1067
|
+
armDeadline?.cancel()
|
|
1068
|
+
armDeadline = nil
|
|
907
1069
|
return
|
|
908
1070
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
// auto-advance fade plays from local disk, not the remote URL.
|
|
912
|
-
let nextItem = cacheUpgradedItem(at: nextIdx)
|
|
913
|
-
applyMixIfNeeded(to: nextItem)
|
|
1071
|
+
let nextIdx = idx + 1
|
|
1072
|
+
let nextItem = rebuiltItem(at: nextIdx)
|
|
914
1073
|
applyPitchAlgorithm(to: nextItem)
|
|
915
1074
|
let standby = standbyPlayer
|
|
916
1075
|
standby.replaceCurrentItem(with: nextItem)
|
|
@@ -918,6 +1077,9 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
918
1077
|
awaitStandbyReady(standby) { [weak self] in
|
|
919
1078
|
guard let self else { return }
|
|
920
1079
|
if self.released || !self.standbyArmed { return }
|
|
1080
|
+
// The status wait's own timeout is cancelled by the flip that got us
|
|
1081
|
+
// here; everything below is unbounded without this.
|
|
1082
|
+
self.startArmDeadline()
|
|
921
1083
|
// Explicit `tracks` key load on the freshly-replaced standby
|
|
922
1084
|
// asset BEFORE preroll. `AVPlayerItem.status == .readyToPlay`
|
|
923
1085
|
// does NOT guarantee the asset's tracks key is loaded —
|
|
@@ -925,32 +1087,50 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
925
1087
|
// tracks to be present so it can attach the volume-ramp
|
|
926
1088
|
// audioMix to the standby item.
|
|
927
1089
|
let nextAsset = nextItem.asset
|
|
928
|
-
Task {
|
|
1090
|
+
Task { [weak self] in
|
|
929
1091
|
do {
|
|
930
1092
|
_ = try await nextAsset.load(.tracks)
|
|
931
1093
|
} catch {
|
|
932
|
-
self
|
|
1094
|
+
if let self { await self.onOwnerQueue { self.cancelFadeInternal() } }
|
|
933
1095
|
return
|
|
934
1096
|
}
|
|
935
1097
|
guard let self else { return }
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
1098
|
+
// The preroll rate is read on the owner queue with the arm check,
|
|
1099
|
+
// so it is the speed set while the fade was being armed.
|
|
1100
|
+
let (armed, prerollRate) = await self.onOwnerQueue { () -> (Bool, Float) in
|
|
1101
|
+
if self.released || !self.standbyArmed { return (false, 1.0) }
|
|
1102
|
+
// Provider mix goes on here, between the tracks load and the
|
|
1103
|
+
// preroll: `audioMix(for:)` yields nil for an item whose tracks
|
|
1104
|
+
// key isn't loaded, and AVPlayer drops an `audioMix` set once the
|
|
1105
|
+
// item has begun rendering. That leaves this the only window in
|
|
1106
|
+
// which a remote incoming item can pick up the EQ / ReplayGain /
|
|
1107
|
+
// visualizer tap, and `runFade` schedules its ramp onto whatever
|
|
1108
|
+
// input parameters are attached by then. Read the item back off
|
|
1109
|
+
// the leg — the queue slot can be rebuilt across the await.
|
|
1110
|
+
if let attached = standby.currentItem {
|
|
1111
|
+
self.applyMixIfNeeded(to: attached)
|
|
1112
|
+
}
|
|
1113
|
+
standby.automaticallyWaitsToMinimizeStalling = false
|
|
1114
|
+
return (true, self.userPlaybackSpeed)
|
|
1115
|
+
}
|
|
1116
|
+
guard armed else { return }
|
|
1117
|
+
// Await preroll via a checked continuation rather than spawning a
|
|
1118
|
+
// fresh Task inside `preroll`'s @escaping completion, which would
|
|
1119
|
+
// warn about a @Sendable closure capturing non-Sendable self —
|
|
1120
|
+
// preroll's callback queue is unspecified.
|
|
943
1121
|
_ = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
944
|
-
standby.preroll(atRate:
|
|
1122
|
+
standby.preroll(atRate: prerollRate) { ready in
|
|
945
1123
|
cont.resume(returning: ready)
|
|
946
1124
|
}
|
|
947
1125
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1126
|
+
await self.onOwnerQueue {
|
|
1127
|
+
if self.released || !self.standbyArmed { return }
|
|
1128
|
+
// Best-effort restore: cancel/release paths drop the
|
|
1129
|
+
// standby item entirely, so a missed restore here is
|
|
1130
|
+
// bounded by the next replaceCurrentItem.
|
|
1131
|
+
standby.automaticallyWaitsToMinimizeStalling = priorAutoStall
|
|
1132
|
+
self.runFade(durationSeconds: durationSeconds)
|
|
1133
|
+
}
|
|
954
1134
|
}
|
|
955
1135
|
}
|
|
956
1136
|
}
|
|
@@ -962,6 +1142,22 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
962
1142
|
/// `standbyArmed` before acting. Backed by a 5s wall-clock
|
|
963
1143
|
/// timeout that calls `cancelFadeInternal` if the status never
|
|
964
1144
|
/// flips (stuck-load defence).
|
|
1145
|
+
/// Bound the arm chain so a standby leg that never becomes playable cannot
|
|
1146
|
+
/// leave the engine armed indefinitely. Generous relative to the ready-wait:
|
|
1147
|
+
/// this is the backstop for everything after it, not a second attempt at the
|
|
1148
|
+
/// same deadline.
|
|
1149
|
+
private func startArmDeadline() {
|
|
1150
|
+
armDeadline?.cancel()
|
|
1151
|
+
let deadline = DispatchWorkItem { [weak self] in
|
|
1152
|
+
guard let self else { return }
|
|
1153
|
+
self.armDeadline = nil
|
|
1154
|
+
guard self.standbyArmed, !self.fadeInFlight else { return }
|
|
1155
|
+
self.cancelFadeInternal()
|
|
1156
|
+
}
|
|
1157
|
+
armDeadline = deadline
|
|
1158
|
+
ownerQueue.asyncAfter(deadline: .now() + Self.armDeadlineSeconds, execute: deadline)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
965
1161
|
private func awaitStandbyReady(_ player: AVPlayer, ready: @escaping () -> Void) {
|
|
966
1162
|
standbyReadyObserver?.invalidate()
|
|
967
1163
|
standbyReadyObserver = nil
|
|
@@ -979,10 +1175,10 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
979
1175
|
let s = obs.status
|
|
980
1176
|
// `.unknown` is the start state — wait for the first real flip.
|
|
981
1177
|
guard s == .readyToPlay || s == .failed else { return }
|
|
982
|
-
|
|
983
|
-
|
|
1178
|
+
guard let self else { return }
|
|
1179
|
+
self.ownerQueue.async {
|
|
984
1180
|
// Cross-cycle race guard: if a new armCrossfade cycle
|
|
985
|
-
// started after this observer queued its
|
|
1181
|
+
// started after this observer queued its `ownerQueue` hop,
|
|
986
1182
|
// the generation counter has advanced — this fire belongs
|
|
987
1183
|
// to a stale standby and must not run `ready()` against
|
|
988
1184
|
// the new arm's state.
|
|
@@ -1011,7 +1207,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1011
1207
|
self.cancelFadeInternal()
|
|
1012
1208
|
}
|
|
1013
1209
|
standbyReadyTimeout = timeout
|
|
1014
|
-
|
|
1210
|
+
self.ownerQueue.asyncAfter(deadline: .now() + 5.0, execute: timeout)
|
|
1015
1211
|
}
|
|
1016
1212
|
|
|
1017
1213
|
/// Input parameters to drive the fade ramp on [item]. Reuses the item's
|
|
@@ -1039,6 +1235,18 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1039
1235
|
/// standby to leading.
|
|
1040
1236
|
private func runFade(durationSeconds: TimeInterval) {
|
|
1041
1237
|
if released { return }
|
|
1238
|
+
// One envelope per boundary. The arm chain that reaches here is
|
|
1239
|
+
// asynchronous — a standby-ready wait, then a `tracks` key load, then a hop
|
|
1240
|
+
// back to the owner queue — and every step of it checks `standbyArmed`
|
|
1241
|
+
// only, so a boundary whose chain is entered more than once arrives here
|
|
1242
|
+
// more than once. Each arrival would schedule another fifty sub-ramps onto
|
|
1243
|
+
// the same `AVMutableAudioMixInputParameters` (`fadeParams` reuses the
|
|
1244
|
+
// leg's existing one so its audio tap survives the fade), and those windows
|
|
1245
|
+
// overlap the ones already on it: `setVolumeRamp` answers an overlapping
|
|
1246
|
+
// window by raising, which is not catchable from Swift and takes the
|
|
1247
|
+
// process with it. The envelope is also simply wrong once it is scheduled
|
|
1248
|
+
// twice, crash or no crash.
|
|
1249
|
+
if fadeInFlight { return }
|
|
1042
1250
|
guard let leading = leadingPlayer.currentItem,
|
|
1043
1251
|
let standby = standbyPlayer.currentItem else { return }
|
|
1044
1252
|
|
|
@@ -1068,9 +1276,24 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1068
1276
|
return
|
|
1069
1277
|
}
|
|
1070
1278
|
|
|
1279
|
+
// The leading ramp anchors on the leg's current play position; a
|
|
1280
|
+
// non-numeric CMTime (an unresolved timebase — a leg that reached the fade
|
|
1281
|
+
// before its clock settled, which host load makes more likely) traps
|
|
1282
|
+
// AVFoundation's `setVolumeRamp` on the invalid time range. Abort the fade
|
|
1283
|
+
// cleanly instead. The standby ramp anchors at kCMTimeZero and is exempt.
|
|
1284
|
+
let leadingFadeStart = leading.currentTime()
|
|
1285
|
+
guard leadingFadeStart.isNumeric else {
|
|
1286
|
+
NSLog("[RNQP-CROSSFADE] runFade aborting — leading currentTime not numeric; cancelling fade")
|
|
1287
|
+
cancelFadeInternal()
|
|
1288
|
+
return
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1071
1291
|
fadeInFlight = true
|
|
1292
|
+
// The chain reached the fade, so its backstop has done its job; the fade
|
|
1293
|
+
// has its own completion path.
|
|
1294
|
+
armDeadline?.cancel()
|
|
1295
|
+
armDeadline = nil
|
|
1072
1296
|
|
|
1073
|
-
let leadingFadeStart = leading.currentTime()
|
|
1074
1297
|
let ramps = Self.equalPowerSubRamps(durationSeconds: durationSeconds)
|
|
1075
1298
|
|
|
1076
1299
|
// Reuse each leg's EXISTING tap-bearing input parameters so the EQ /
|
|
@@ -1083,6 +1306,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1083
1306
|
// bare ramp-only params object only when the leg carries no tap (no
|
|
1084
1307
|
// EQ/viz/RG consumer, HLS, or tracks not yet loaded).
|
|
1085
1308
|
let leadingParams = fadeParams(for: leading, track: leadingTrack)
|
|
1309
|
+
rampsScheduled = true
|
|
1086
1310
|
for ramp in ramps {
|
|
1087
1311
|
let absStart = leadingFadeStart + ramp.timeRange.start
|
|
1088
1312
|
let timeRange = CMTimeRange(start: absStart, duration: ramp.timeRange.duration)
|
|
@@ -1118,7 +1342,10 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1118
1342
|
standby.audioMix = standbyMix
|
|
1119
1343
|
|
|
1120
1344
|
standbyPlayer.volume = userVolume // ramp inside the mix handles the actual envelope
|
|
1121
|
-
|
|
1345
|
+
// Start at the user's speed rather than `play()`'s implicit 1.0, so the
|
|
1346
|
+
// incoming track is not audibly played at normal speed for the length of
|
|
1347
|
+
// the fade and then snapped.
|
|
1348
|
+
standbyPlayer.rate = userPlaybackSpeed
|
|
1122
1349
|
|
|
1123
1350
|
// Active-track flip: the standby leg is now audibly playing
|
|
1124
1351
|
// (volume 0 → ramping up). Notify the delegate so JS-facing
|
|
@@ -1128,8 +1355,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1128
1355
|
// etc.) also flips at this point because `fadeInFlight = true`
|
|
1129
1356
|
// is already set above; the delegate sees consistent state
|
|
1130
1357
|
// across the callback.
|
|
1131
|
-
|
|
1132
|
-
delegate?.engineCrossfadeDidBegin(self, incomingIndex: incomingIdx)
|
|
1358
|
+
delegate?.engineCrossfadeDidBegin(self)
|
|
1133
1359
|
|
|
1134
1360
|
// Post-fade swap scheduled in wall-clock — the asset-time-keyed
|
|
1135
1361
|
// ramps complete in this many seconds regardless of pause
|
|
@@ -1140,13 +1366,14 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1140
1366
|
// pauses+resumes within one boundary window.
|
|
1141
1367
|
fadeCompletionTask?.cancel()
|
|
1142
1368
|
let durationNs = UInt64(durationSeconds * 1_000_000_000)
|
|
1143
|
-
fadeCompletionTask = Task {
|
|
1369
|
+
fadeCompletionTask = Task { [weak self] in
|
|
1144
1370
|
do {
|
|
1145
1371
|
try await Task.sleep(nanoseconds: durationNs)
|
|
1146
1372
|
} catch {
|
|
1147
1373
|
return // cancelled
|
|
1148
1374
|
}
|
|
1149
|
-
self
|
|
1375
|
+
guard let self else { return }
|
|
1376
|
+
await self.onOwnerQueue { self.completeCrossfade() }
|
|
1150
1377
|
}
|
|
1151
1378
|
}
|
|
1152
1379
|
|
|
@@ -1182,14 +1409,17 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1182
1409
|
newLeading.audioMix = nil
|
|
1183
1410
|
applyMixIfNeeded(to: newLeading)
|
|
1184
1411
|
}
|
|
1185
|
-
// The promoted standby
|
|
1186
|
-
// the
|
|
1412
|
+
// The promoted standby already runs at the user's speed; restamping it
|
|
1413
|
+
// here keeps the promotion total when the speed changed mid-fade.
|
|
1187
1414
|
leadingPlayer.rate = userPlaybackSpeed
|
|
1188
1415
|
standbyArmed = false
|
|
1416
|
+
rampsScheduled = false
|
|
1189
1417
|
currentIndex += 1
|
|
1190
1418
|
startArmingObserver()
|
|
1191
1419
|
installLeadingObservers()
|
|
1192
|
-
|
|
1420
|
+
// Settled before the transition: work held back for this fade belongs to
|
|
1421
|
+
// the run the transition is about to be told to extend.
|
|
1422
|
+
delegate?.engineFadeDidSettle(self)
|
|
1193
1423
|
delegate?.engineDidTransitionTrack(self)
|
|
1194
1424
|
}
|
|
1195
1425
|
|
|
@@ -1200,44 +1430,75 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1200
1430
|
standbyReadyObserver = nil
|
|
1201
1431
|
standbyReadyTimeout?.cancel()
|
|
1202
1432
|
standbyReadyTimeout = nil
|
|
1433
|
+
armDeadline?.cancel()
|
|
1434
|
+
armDeadline = nil
|
|
1203
1435
|
// Capture pre-state: if a fade was in flight we already fired
|
|
1204
1436
|
// `engineCrossfadeDidBegin` for `currentIndex + 1` — the
|
|
1205
1437
|
// delegate believes the incoming is active. We must fire
|
|
1206
|
-
// `engineCrossfadeDidCancel
|
|
1207
|
-
//
|
|
1438
|
+
// `engineCrossfadeDidCancel` so the delegate reverts
|
|
1439
|
+
// active-track state back to the leading
|
|
1208
1440
|
// leg. `emitRevert: false` is for callers that will immediately
|
|
1209
1441
|
// set a new active track themselves (e.g. seek to a different
|
|
1210
1442
|
// index) — emitting the intermediate revert would surface a
|
|
1211
1443
|
// spurious track-change to the outgoing leg before the caller's
|
|
1212
1444
|
// own track-change.
|
|
1213
1445
|
let wasMidFade = fadeInFlight
|
|
1214
|
-
let
|
|
1446
|
+
let wasArmed = standbyArmed
|
|
1447
|
+
let hadScheduledRamp = rampsScheduled
|
|
1215
1448
|
fadeInFlight = false
|
|
1216
1449
|
standbyArmed = false
|
|
1217
|
-
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1450
|
+
rampsScheduled = false
|
|
1451
|
+
// Workaround: a backward seek through a scheduled ramp window
|
|
1452
|
+
// leaves the mix in place with the wrong volume — strip the mix
|
|
1453
|
+
// and re-apply the EQ-only mix to recover. See the type doc for
|
|
1454
|
+
// the source.
|
|
1455
|
+
//
|
|
1456
|
+
// Only when a ramp was scheduled: this runs on the item that is already
|
|
1457
|
+
// rendering, and re-applying builds a fresh processing tap that AVPlayer
|
|
1458
|
+
// silently drops on an item already in flight. Callers that cancel
|
|
1459
|
+
// routinely — every `remove` in a rebuild's removal loop — would otherwise
|
|
1460
|
+
// pay one dropped tap cascade per item removed.
|
|
1461
|
+
if hadScheduledRamp {
|
|
1462
|
+
if let leading = leadingPlayer.currentItem {
|
|
1463
|
+
leading.audioMix = nil
|
|
1464
|
+
applyMixIfNeeded(to: leading)
|
|
1465
|
+
}
|
|
1466
|
+
if let standby = standbyPlayer.currentItem {
|
|
1467
|
+
standby.audioMix = nil
|
|
1468
|
+
}
|
|
1226
1469
|
}
|
|
1227
1470
|
leadingPlayer.volume = userVolume
|
|
1228
1471
|
standbyPlayer.volume = 0
|
|
1472
|
+
// `runFade` gave this leg a rate. Dropping its item does not stop it, so
|
|
1473
|
+
// without the pause the next arm attaches to a leg already advancing: its
|
|
1474
|
+
// mix is set on an item that has begun rendering and is silently dropped,
|
|
1475
|
+
// and the fade's ramp, anchored at asset-time zero, is already in the past.
|
|
1476
|
+
standbyPlayer.pause()
|
|
1477
|
+
// The arm turns this off as a documented precondition of `preroll` and
|
|
1478
|
+
// restores it once prerolled. A cancel in between skips that restore, and
|
|
1479
|
+
// the value survives the role flip that makes this leg the leading one —
|
|
1480
|
+
// where it means playback resumes into a stall instead of waiting.
|
|
1481
|
+
standbyPlayer.automaticallyWaitsToMinimizeStalling = true
|
|
1229
1482
|
standbyPlayer.replaceCurrentItem(with: nil)
|
|
1230
1483
|
if wasMidFade && emitRevert {
|
|
1231
|
-
delegate?.engineCrossfadeDidCancel(self
|
|
1484
|
+
delegate?.engineCrossfadeDidCancel(self)
|
|
1485
|
+
}
|
|
1486
|
+
// Every end of a fade, not just a reverted one. A cancel taken during the
|
|
1487
|
+
// arm window emits no revert — there is no track change to undo — but work
|
|
1488
|
+
// held back for the fade still has to be released, and pause, stop, the
|
|
1489
|
+
// arm deadline, a standby that fails to load and a route change all end
|
|
1490
|
+
// here without ever reaching `wasMidFade`.
|
|
1491
|
+
if wasMidFade || hadScheduledRamp || wasArmed {
|
|
1492
|
+
delegate?.engineFadeDidSettle(self)
|
|
1232
1493
|
}
|
|
1233
1494
|
}
|
|
1234
1495
|
|
|
1235
1496
|
// MARK: - Engine event observer install/teardown
|
|
1236
1497
|
|
|
1237
1498
|
/// Install KVO on the leading leg's `timeControlStatus` (drives
|
|
1238
|
-
/// `engineStateMaybeChanged`
|
|
1239
|
-
///
|
|
1240
|
-
///
|
|
1499
|
+
/// `engineStateMaybeChanged`) and on the leading leg's currentItem
|
|
1500
|
+
/// `tracks` / `status` (drives format-ready + failed-to-load
|
|
1501
|
+
/// delegate fires). Called whenever
|
|
1241
1502
|
/// the leading leg or its currentItem changes — i.e. from
|
|
1242
1503
|
/// [setItems], [seek], and [completeCrossfade] after the role
|
|
1243
1504
|
/// flip. Idempotent — teardown runs first inside.
|
|
@@ -1246,15 +1507,34 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1246
1507
|
let player = leadingPlayer
|
|
1247
1508
|
leadingTimeControlObserver = player.observe(
|
|
1248
1509
|
\.timeControlStatus, options: [.new]
|
|
1249
|
-
) { [weak self]
|
|
1250
|
-
let
|
|
1251
|
-
|
|
1252
|
-
guard let self else { return }
|
|
1510
|
+
) { [weak self] _, _ in
|
|
1511
|
+
guard let self else { return }
|
|
1512
|
+
self.ownerQueue.async {
|
|
1253
1513
|
self.delegate?.engineStateMaybeChanged(self, reason: nil)
|
|
1254
|
-
self.delegate?.engineIsPlayingChanged(self, isPlaying: isPlayingNow)
|
|
1255
1514
|
}
|
|
1256
1515
|
}
|
|
1257
1516
|
if let item = player.currentItem {
|
|
1517
|
+
// A local file that does not exist is deterministically unplayable, and
|
|
1518
|
+
// its `AVURLAsset` load can stall in `.unknown` indefinitely on `AVPlayer`
|
|
1519
|
+
// under host load — the item never reaches `.failed`, so a status observer
|
|
1520
|
+
// would never surface it and the leg sits buffering with no error. Fail it
|
|
1521
|
+
// now, and install no status observer for this item: a load that does
|
|
1522
|
+
// eventually reach `.failed` would otherwise re-report the same dead
|
|
1523
|
+
// source through the observer below, under a second error code that is
|
|
1524
|
+
// fatal where the missing-file code is transient — the retry path. A retry
|
|
1525
|
+
// re-seats the item and re-runs this check, so a source that appears is
|
|
1526
|
+
// picked up then. Read the asset's own URL, the one the player will load.
|
|
1527
|
+
if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
|
|
1528
|
+
!FileManager.default.fileExists(atPath: asset.url.path) {
|
|
1529
|
+
let missing = NSError(
|
|
1530
|
+
domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist,
|
|
1531
|
+
userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."])
|
|
1532
|
+
self.ownerQueue.async { [weak self] in
|
|
1533
|
+
guard let self else { return }
|
|
1534
|
+
self.delegate?.engineDidFailWithError(self, error: missing, item: item)
|
|
1535
|
+
}
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1258
1538
|
leadingItemTracksObserver = item.observe(
|
|
1259
1539
|
\.tracks, options: [.new]
|
|
1260
1540
|
) { [weak self] obs, _ in
|
|
@@ -1263,8 +1543,8 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1263
1543
|
// tracks array is populated so the gapless KVO path's
|
|
1264
1544
|
// dedup contract is matched.
|
|
1265
1545
|
guard !obs.tracks.isEmpty else { return }
|
|
1266
|
-
|
|
1267
|
-
|
|
1546
|
+
guard let self else { return }
|
|
1547
|
+
self.ownerQueue.async {
|
|
1268
1548
|
self.delegate?.engineActiveItemFormatChanged(self)
|
|
1269
1549
|
}
|
|
1270
1550
|
}
|
|
@@ -1274,21 +1554,122 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1274
1554
|
// propagates and the now-playing format stays nil. Mirrors what
|
|
1275
1555
|
// `.initial` would have delivered.
|
|
1276
1556
|
if !item.tracks.isEmpty {
|
|
1277
|
-
|
|
1557
|
+
self.ownerQueue.async { [weak self] in
|
|
1278
1558
|
guard let self else { return }
|
|
1279
1559
|
self.delegate?.engineActiveItemFormatChanged(self)
|
|
1280
1560
|
}
|
|
1281
1561
|
}
|
|
1562
|
+
// Leading-leg initial-buffer leniency, mirroring the gapless engine's
|
|
1563
|
+
// `gaplessFlipArmed`. A freshly seated item waits to minimize stalling
|
|
1564
|
+
// until it is `.readyToPlay`, then plays without further waiting. Without
|
|
1565
|
+
// the flip the leading leg keeps `automaticallyWaitsToMinimizeStalling`
|
|
1566
|
+
// set after a resume / engine swap and can take longer than a caller's
|
|
1567
|
+
// settle window to reach `.playing`. Re-arm to `true` for a not-yet-ready
|
|
1568
|
+
// item so a play-before-buffered waits rather than stalling to paused;
|
|
1569
|
+
// flip to `false` once the item is ready so a resume starts promptly.
|
|
1570
|
+
// `player` is captured so the leg that owns this item is the one flipped
|
|
1571
|
+
// even if the leading role has since swapped.
|
|
1572
|
+
player.automaticallyWaitsToMinimizeStalling = item.status != .readyToPlay
|
|
1282
1573
|
leadingItemStatusObserver = item.observe(
|
|
1283
1574
|
\.status, options: [.new]
|
|
1284
1575
|
) { [weak self] obs, _ in
|
|
1576
|
+
if obs.status == .readyToPlay {
|
|
1577
|
+
// Flip on `ownerQueue` like every other mutation here (and the gapless
|
|
1578
|
+
// flip in `dispatchItemStatus`), re-checking the item is still the
|
|
1579
|
+
// leg's current one. KVO does not abort an in-flight callback on
|
|
1580
|
+
// `invalidate()`, so a stale `.readyToPlay` for a since-replaced item
|
|
1581
|
+
// must not strand `autoWaits = false` on a fresh not-yet-ready item —
|
|
1582
|
+
// the very stall the re-arm above prevents.
|
|
1583
|
+
self?.ownerQueue.async {
|
|
1584
|
+
guard let self, player.currentItem === obs else { return }
|
|
1585
|
+
if self.mixProvider?.anyConsumerWantsTap() == true {
|
|
1586
|
+
// A tap is wanted. Install this item's audio tap BEFORE
|
|
1587
|
+
// permitting prompt render: AVPlayer honours `audioMix` only
|
|
1588
|
+
// before the item starts rendering (Apple contract, the same one
|
|
1589
|
+
// `GaplessEngine.setItems` installs before enqueue for) — a mix
|
|
1590
|
+
// set once the leg is already playing is silently dropped, its
|
|
1591
|
+
// `MTAudioProcessingTap` never prepares, and the equaliser and
|
|
1592
|
+
// visualizer tap lose their audio. `.readyToPlay` does not
|
|
1593
|
+
// guarantee the asset's `tracks` key is loaded and
|
|
1594
|
+
// `audioMix(for:)` yields nil until it is, so load tracks first
|
|
1595
|
+
// (as the arm path does for the standby), then install the mix
|
|
1596
|
+
// and resume any leg held at rate 0 for the tap. The leg stays
|
|
1597
|
+
// held (`autoWaits` true) until that completes; a consumer that
|
|
1598
|
+
// subscribes mid-playback then joins the already-running tap
|
|
1599
|
+
// instead of needing an unreliable mid-render re-attach.
|
|
1600
|
+
self.installLeadingTapMixThenResume(item: obs)
|
|
1601
|
+
} else {
|
|
1602
|
+
// No tap wanted: nothing to install; permit prompt render, and
|
|
1603
|
+
// resume a leg that was held for a tap since dropped (a consumer
|
|
1604
|
+
// unsubscribed before this fired) — leaving it at rate 0 would
|
|
1605
|
+
// strand it silent. A genuine pause has cleared `wantsPlayback`
|
|
1606
|
+
// (via `stopArmingObserver`), so this does not override it.
|
|
1607
|
+
player.automaticallyWaitsToMinimizeStalling = false
|
|
1608
|
+
if self.wantsPlayback, player.rate == 0 {
|
|
1609
|
+
player.rate = self.userPlaybackSpeed
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1285
1615
|
guard obs.status == .failed else { return }
|
|
1286
1616
|
let underlying = obs.error ?? NSError(
|
|
1287
1617
|
domain: "CrossfadeEngine", code: -1, userInfo: nil
|
|
1288
1618
|
)
|
|
1289
|
-
|
|
1619
|
+
guard let self else { return }
|
|
1620
|
+
// `obs` is the leading leg's current item — the one that failed.
|
|
1621
|
+
self.ownerQueue.async {
|
|
1622
|
+
self.delegate?.engineDidFailWithError(self, error: underlying, item: obs)
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
// Bootstrap: `.new`-only KVO won't fire when the item is ALREADY `.failed`
|
|
1626
|
+
// at install time — a source that reached `.failed` between `setItems` and
|
|
1627
|
+
// the reinstall a `seek` runs. The reinstalled observer never sees the
|
|
1628
|
+
// transition, so surface it here, as `.initial` and the tracks observer
|
|
1629
|
+
// above both do.
|
|
1630
|
+
if item.status == .failed {
|
|
1631
|
+
let underlying = item.error ?? NSError(
|
|
1632
|
+
domain: "CrossfadeEngine", code: -1, userInfo: nil
|
|
1633
|
+
)
|
|
1634
|
+
self.ownerQueue.async { [weak self] in
|
|
1635
|
+
guard let self else { return }
|
|
1636
|
+
self.delegate?.engineDidFailWithError(self, error: underlying, item: item)
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
// Buffer-flag KVO so a discrete buffer-state change reaches
|
|
1640
|
+
// `onBufferStateChange` on the crossfade engine, the same way the gapless
|
|
1641
|
+
// `AVQueuePlayer`'s item KVO delivers it. Without it the buffer surface
|
|
1642
|
+
// recomputes only on transport calls and the 500ms progress tick, so a
|
|
1643
|
+
// fast local reload's empty→full edge lands between samples and no change
|
|
1644
|
+
// is emitted. Routed to a delegate that recomputes ONLY the buffer surface
|
|
1645
|
+
// — no direct player-state emission; folding buffer edges into the state
|
|
1646
|
+
// path would churn `PlayerState` on every load edge.
|
|
1647
|
+
let onBuffer: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
|
|
1648
|
+
guard let self else { return }
|
|
1649
|
+
self.ownerQueue.async { [weak self] in
|
|
1650
|
+
guard let self else { return }
|
|
1651
|
+
self.delegate?.engineBufferStateMaybeChanged(self)
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
leadingItemBufferEmptyObserver = item.observe(
|
|
1655
|
+
\.isPlaybackBufferEmpty, options: [.new], changeHandler: onBuffer)
|
|
1656
|
+
leadingItemBufferKeepUpObserver = item.observe(
|
|
1657
|
+
\.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onBuffer)
|
|
1658
|
+
leadingItemBufferFullObserver = item.observe(
|
|
1659
|
+
\.isPlaybackBufferFull, options: [.new], changeHandler: onBuffer)
|
|
1660
|
+
// Bootstrap: `.new`-only KVO won't fire for an item ALREADY buffer-ready
|
|
1661
|
+
// at install time — a cached / preloaded skip. Deliver the current reading
|
|
1662
|
+
// once, as the tracks / status bootstraps above do. Guarded on the ready
|
|
1663
|
+
// flags for the same reason those are guarded on `!tracks.isEmpty` /
|
|
1664
|
+
// `.failed`: bootstrap only when there is a settled state to deliver. An
|
|
1665
|
+
// unconditional fire queues a recompute on the owner queue on EVERY
|
|
1666
|
+
// seat / seek / advance — most of which seat a not-yet-buffered item that
|
|
1667
|
+
// the KVO will report when it settles — and that per-install churn shifts
|
|
1668
|
+
// the crossfade arm / preroll interleave on the serial queue under load.
|
|
1669
|
+
if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
|
|
1670
|
+
self.ownerQueue.async { [weak self] in
|
|
1290
1671
|
guard let self else { return }
|
|
1291
|
-
self.delegate?.
|
|
1672
|
+
self.delegate?.engineBufferStateMaybeChanged(self)
|
|
1292
1673
|
}
|
|
1293
1674
|
}
|
|
1294
1675
|
}
|
|
@@ -1301,6 +1682,12 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1301
1682
|
leadingItemTracksObserver = nil
|
|
1302
1683
|
leadingItemStatusObserver?.invalidate()
|
|
1303
1684
|
leadingItemStatusObserver = nil
|
|
1685
|
+
leadingItemBufferEmptyObserver?.invalidate()
|
|
1686
|
+
leadingItemBufferEmptyObserver = nil
|
|
1687
|
+
leadingItemBufferKeepUpObserver?.invalidate()
|
|
1688
|
+
leadingItemBufferKeepUpObserver = nil
|
|
1689
|
+
leadingItemBufferFullObserver?.invalidate()
|
|
1690
|
+
leadingItemBufferFullObserver = nil
|
|
1304
1691
|
}
|
|
1305
1692
|
|
|
1306
1693
|
private func handleItemDidPlayToEnd(_ note: Notification) {
|
|
@@ -1314,7 +1701,12 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1314
1701
|
// every track (incl. the last), so it must precede the queue-end check.
|
|
1315
1702
|
if repeatMode == .track, !fadeInFlight, currentIndex >= 0 {
|
|
1316
1703
|
leadingPlayer.seek(to: .zero)
|
|
1317
|
-
|
|
1704
|
+
// `play()` resets the rate to 1.0, dropping the user's speed on every
|
|
1705
|
+
// loop; and the notification hops onto the owner queue, so a pause taken
|
|
1706
|
+
// in between must not be undone here.
|
|
1707
|
+
if wantsPlayback {
|
|
1708
|
+
leadingPlayer.rate = userPlaybackSpeed
|
|
1709
|
+
}
|
|
1318
1710
|
return
|
|
1319
1711
|
}
|
|
1320
1712
|
// Queue-end detection: leading played its last item to end
|
|
@@ -1328,7 +1720,60 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1328
1720
|
currentIndex >= 0,
|
|
1329
1721
|
currentIndex >= queue.count - 1 {
|
|
1330
1722
|
delegate?.enginePlaybackEnded(self)
|
|
1723
|
+
return
|
|
1724
|
+
}
|
|
1725
|
+
// The item ended mid-run with no fade to carry the boundary. Advancing
|
|
1726
|
+
// is this engine's job either way: a fade is how it usually crosses, not
|
|
1727
|
+
// the only way it can.
|
|
1728
|
+
//
|
|
1729
|
+
// Reachable whenever the arm did not happen or did not survive — the
|
|
1730
|
+
// standby failed to load, the arm chain timed out, a route change or a
|
|
1731
|
+
// queue mutation cancelled it, or the item was the last one installed and
|
|
1732
|
+
// there was nothing to preroll. Without this the queue simply stops on a
|
|
1733
|
+
// track that has finished, and nothing arrives to restart it.
|
|
1734
|
+
if !fadeInFlight, currentIndex >= 0, currentIndex < queue.count - 1 {
|
|
1735
|
+
advanceWithoutFade()
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/// Attach the next queue item to the leading leg and advance the index, for
|
|
1740
|
+
/// an item that reached its end with no fade to carry the boundary.
|
|
1741
|
+
///
|
|
1742
|
+
/// Follows `seek(toIndex:)`: capture playback intent, tear the fade state
|
|
1743
|
+
/// down through the one path that owns it, rebuild the incoming item, and
|
|
1744
|
+
/// resume only if the engine was playing.
|
|
1745
|
+
private func advanceWithoutFade() {
|
|
1746
|
+
if released { return }
|
|
1747
|
+
// The end-of-item notification hops onto the owner queue, so a pause taken
|
|
1748
|
+
// in between must not be undone by the rate write below.
|
|
1749
|
+
let shouldResume = wantsPlayback
|
|
1750
|
+
// Never by hand: the arm leaves a KVO observation, two timers and a task
|
|
1751
|
+
// behind, and the standby leg holding a buffering item. Suppress the
|
|
1752
|
+
// revert emit — the transition fired at the end of this method is the
|
|
1753
|
+
// authoritative track change.
|
|
1754
|
+
cancelFadeInternal(emitRevert: false)
|
|
1755
|
+
// `engineFadeDidSettle` above releases work deferred for the fade, which
|
|
1756
|
+
// can reconcile the run. Re-read the bounds it may have moved.
|
|
1757
|
+
guard currentIndex >= 0, currentIndex + 1 < queue.count else { return }
|
|
1758
|
+
teardownLeadingObservers()
|
|
1759
|
+
let nextIndex = currentIndex + 1
|
|
1760
|
+
// Fresh instance, never the stored one: the queue slot may still hold the
|
|
1761
|
+
// item the arm attached to the standby leg, and attaching an item that is
|
|
1762
|
+
// still associated with another player raises an uncatchable exception.
|
|
1763
|
+
let next = rebuiltItem(at: nextIndex)
|
|
1764
|
+
// Both before the attach: AVPlayer drops an `audioMix` set on an item that
|
|
1765
|
+
// has begun rendering, leaking the processing tap it carries.
|
|
1766
|
+
applyMixIfNeeded(to: next)
|
|
1767
|
+
applyPitchAlgorithm(to: next)
|
|
1768
|
+
leadingPlayer.replaceCurrentItem(with: next)
|
|
1769
|
+
currentIndex = nextIndex
|
|
1770
|
+
if shouldResume {
|
|
1771
|
+
// `replaceCurrentItem` leaves the player at rate 0 — resume respecting a
|
|
1772
|
+
// wanted tap, as `seek(toIndex:)` does.
|
|
1773
|
+
resumeLeadingRespectingTap()
|
|
1331
1774
|
}
|
|
1775
|
+
installLeadingObservers()
|
|
1776
|
+
delegate?.engineDidTransitionTrack(self)
|
|
1332
1777
|
}
|
|
1333
1778
|
|
|
1334
1779
|
private func handleItemFailedToPlayToEnd(_ note: Notification) {
|
|
@@ -1337,6 +1782,7 @@ final class CrossfadeEngine: PlaybackEngine {
|
|
|
1337
1782
|
let underlying = (note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error)
|
|
1338
1783
|
?? item.error
|
|
1339
1784
|
?? NSError(domain: "CrossfadeEngine", code: -2, userInfo: nil)
|
|
1340
|
-
|
|
1785
|
+
// Guarded above as the leading leg's current item.
|
|
1786
|
+
delegate?.engineDidFailWithError(self, error: underlying, item: item)
|
|
1341
1787
|
}
|
|
1342
1788
|
}
|