Skip to content

fix(mobile): keep redux play state in sync when the iOS queue ends - #14653

Merged
dylanjeffers merged 1 commit into
mainfrom
fix/mobile-ios-queue-end-stall
Oct 9, 2026
Merged

dylanjeffers merged 1 commit into
mainfrom
fix/mobile-ios-queue-end-stall

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Bug

On iOS, after a list plays to its end:

  • the play button keeps showing pause until it's tapped
  • pressing Previous and letting the track "play" to the end stalls. The scrubber moves, but the native player is paused the whole time, so it never sends an end event (no PlaybackQueueEnded, no PlaybackActiveTrackChanged).

Both symptoms come from the same cause. They aren't related to #14649 or to the SwiftAudioEx index bug in #14650: native currentIndex is correct at every step.

Root cause

When the queue ends, SwiftAudioEx stops with playWhenReady = false and RNTP emits PlaybackQueueEnded. AudioPlayer.tsx subscribes to that event but has never handled it. It was a // TODO before the #14220 refactor, and it was silently dropped after. So redux keeps playing: true.

TrackPlayer.play() is only called from handleTogglePlay, when redux playing changes. After the queue ends it is already true, so:

  • Previous (position > 3s) dispatches reset({ shouldAutoplay: true }), which seeks to 0 and sets playing: true again. That is not a change, so play() is never called.
  • Previous twice / tapping a track calls skip(index). RNTP iOS calls jumpToItem(atIndex:, playWhenReady: playerState == .playing), which is false here, so the track loads paused.

The same skip behavior also stalls without a queue end: Next then Previous quickly skips while the player is still buffering, so the track loads paused while redux says playing.

Simulator logs from an instrumented build (native state read through RNTP after each event):

queue end    playback-queue-ended  state=paused pwr=false idx=1    redux playing=true   <- out of sync
Previous     redux counter++ (reset)  state=ready pwr=false pos=0    redux playing=true   <- never plays
Prev, Prev   skip(0) from paused   state=ready pwr=false idx=0                          <- never plays
Next, Prev   skip(0) from buffering  playWhenReady -> false, state=ready                <- never plays

Fix

  • On PlaybackQueueEnded, dispatch pause({ onlySetState: true }) so redux matches the stopped player. The next play is then a real false -> true change and reaches TrackPlayer.play().
  • After TrackPlayer.skip() in handleQueueIdxChange, call TrackPlayer.play() if redux is playing. On Android, skip already keeps playWhenReady, so this does nothing there.

JS only, so it can ship OTA.

Testing

iOS 26.5 simulator, Release build, old arch (JS thread is com.facebook.react.JavaScript), production env, with this branch's bundle. I used a 2-track album and an instrumented bundle that logs native state on each RNTP event and auto-seeks near the end of each track.

  • Before:
    • queue end leaves pwr=false with redux playing=true, and the album and mini player show Pause
    • Previous leaves native paused at 0 while the scrubber advances
    • Previous ×2 and Next→Previous load the track paused
  • After:
    • queue end sets redux playing=false, and the album and mini player show Play
    • Previous restarts and plays to the end, then pauses cleanly
    • Next→Previous during buffering: skip still drops playWhenReady, then play() brings it back, and playback continues and auto-advances
    • tapping a track row from the paused state plays it
  • eslint is clean and the mobile tests pass (8 suites, 25 tests). tsc --noEmit -p packages/mobile shows the same 4 errors with and without this change, in unrelated files.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d73fd4a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dylanjeffers
dylanjeffers merged commit 566dfe2 into main Oct 9, 2026
3 checks passed
@dylanjeffers
dylanjeffers deleted the fix/mobile-ios-queue-end-stall branch October 9, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant