From d73fd4afdc2463bf4919e39d458cb2e3b14507c6 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 8 Oct 2026 11:26:55 -0700 Subject: [PATCH] fix(mobile): keep redux play state in sync when the iOS queue ends When the native queue ends, RNTP fires PlaybackQueueEnded and the player stops with playWhenReady false, but the handler ignored the event, so redux kept `playing: true`. The play button kept showing pause, and the next play (Previous restarting the track, Previous to an earlier track, tapping a track) never called TrackPlayer.play() because `playing` did not change. The track sat paused while the scrubber kept moving. - On PlaybackQueueEnded, mark redux paused. - After TrackPlayer.skip(), call play() if redux is playing. On iOS, skip only keeps playing when the player is in the playing state, so a skip while loading or buffering (Next then Previous quickly) also loaded the new track paused. Co-Authored-By: Claude Opus 5.5 --- .../src/components/audio/AudioPlayer.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/mobile/src/components/audio/AudioPlayer.tsx b/packages/mobile/src/components/audio/AudioPlayer.tsx index 2b79ac94bab..0b37caa463f 100644 --- a/packages/mobile/src/components/audio/AudioPlayer.tsx +++ b/packages/mobile/src/components/audio/AudioPlayer.tsx @@ -173,6 +173,7 @@ const useQueueSync = (isAudioSetup: boolean) => { const dispatch = useDispatch() const queueIndex = useSelector(getIndex) + const playing = useSelector(getPlaying) const queueOrder = useSelector(getPlaybackQueue) const queueSource = useSelector(getSource) const queueCollectionId = useSelector(getCollectionId) @@ -411,6 +412,12 @@ const useQueueSync = (isAudioSetup: boolean) => { // --- handleQueueIdxChange: skip within a synced queue --- const latestQueueIdxRef = useRef(-1) + // Read by handleQueueIdxChange without re-running it on play/pause. + const playingRef = useRef(playing) + useEffect(() => { + playingRef.current = playing + }, [playing]) + const handleQueueIdxChange = useCallback(async () => { if (queueIndex === -1) return @@ -425,6 +432,12 @@ const useQueueSync = (isAudioSetup: boolean) => { const queue = await TrackPlayer.getQueue() if (queueIndex < queue.length) { await TrackPlayer.skip(queueIndex) + // On iOS, skip keeps playing only if the player was in the playing + // state, so a skip while loading, buffering or paused at the end of + // the queue loads the track paused. + if (playingRef.current) { + await TrackPlayer.play() + } } } }, [queueIndex, queueTrackIds]) @@ -604,6 +617,14 @@ const usePlaybackEvents = ({ return } + // --- Queue ended: the native player has stopped, so mark redux paused. + // Otherwise `playing` stays true and the next play (restart, previous, + // tapping a track) never reaches TrackPlayer.play(). + if (event.type === Event.PlaybackQueueEnded) { + dispatch(playbackActions.pause({ onlySetState: true })) + return + } + // --- Active track changed --- if (event.type === Event.PlaybackActiveTrackChanged) { const playerIndex = await TrackPlayer.getActiveTrackIndex()