Skip to content

fix(mobile): advance to next track on iOS when starting mid-list - #14649

Merged
dylanjeffers merged 1 commit into
mainfrom
fix/mobile-ios-queue-advance
Oct 8, 2026
Merged

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

Conversation

@dylanjeffers

@dylanjeffers dylanjeffers commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Bug

On iOS, starting a track that isn't the first one in a list (feed, profile, collection) and letting it finish doesn't advance. The player stays on the same track instead of moving to the next one.

resetQueue adds the tapped track on its own, then inserts the earlier tracks at index 0. SwiftAudioEx (RNTP's iOS player, 1.0.0 and 1.1.0) only shifts its current index for an insert in front of the current item when the queue already has more than one item:

if (self.items.count > 1 && currentIndex >= index) {
    currentIndex += items.count
}

So the first insert in front of the lone tapped track leaves currentIndex one slot too early. When the track ends, "next" is the tapped track again.

Fix

Queue building moves into addTracksAroundCurrent:

  • the later tracks are appended first, so the queue already has more than one item when the earlier tracks go in
  • the earlier tracks go in with a single insert at index 0
  • if there are no later tracks (the tapped track is last), a placeholder copy of the previous track is appended for the insert and removed afterwards. Removing an item after the current one doesn't touch the current index or playback.

The final RNTP order is still the redux order, so Android (KotlinAudio) ends up with the same queue as before.

This is JS only and OTA-safe. The native fix (> 0) is in #14650 for the 1.5.187 release.

Testing

  • New unit test addTracksAroundCurrent.test.ts uses a fake queue that copies SwiftAudioEx's index logic. The old insert order fails 4 of its 6 cases and the new one passes all 6.
  • npx tsc --noEmit -p packages/mobile, eslint and npm test (8 suites, 25 tests) pass.
  • iOS simulator, Release build (old arch) with a production bundle:
    • before the fix: starting the 2nd or 3rd feed track and letting it finish stalls on that track at the end
    • after the fix:
      • 2nd feed track → advances to the 3rd, and the now playing drawer and the highlighted tile match
      • previous goes back to the earlier track
      • first item advances
      • last item of a 2-track album: RNTP reports active index 1 with the queue in order, and playback ends at the end of the queue

🤖 Generated with Claude Code

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: fa25ce7

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 1b3ddf8 into main Oct 8, 2026
3 checks passed
@dylanjeffers
dylanjeffers deleted the fix/mobile-ios-queue-advance branch October 8, 2026 08:41
dylanjeffers added a commit that referenced this pull request Oct 9, 2026
)

Ships in native release **1.5.187**. It targets
`chore/mobile-native-release-1.5.187` (#14645).

## What
A `post_install` step in the Podfile patches one line of the
SwiftAudioEx pod source, `Sources/SwiftAudioEx/QueueManager.swift`:

```diff
-            if (self.items.count > 1 && currentIndex >= index) {
+            if (self.items.count > 0 && currentIndex >= index) {
```

SwiftAudioEx 1.0.0 (our Podfile.lock) and 1.1.0 (RNTP 4.1.2's podspec)
only shift the current index for an insert in front of the current item
when the queue already has more than one item. With one item, inserting
at 0 leaves the index one slot early, and when the track ends the player
replays it instead of advancing. Upstream `main` has the `> 0` fix, but
it isn't released yet.

The step replaces only that exact line. If the file is already patched
it does nothing. If the line is missing (for example after a
SwiftAudioEx update), it raises and `pod install` fails, so the patch
can't be skipped silently.

## Relation to #14649
#14649 is the JS fix, which ships OTA. It avoids the bad insert by
appending the later tracks first. When the tapped track is the last one
in the list, it uses a temporary placeholder instead. This PR fixes the
root cause natively, which covers the last-item case without the
placeholder. It also covers any other single-item insert.

## Testing
- `RCT_NEW_ARCH_ENABLED=0 bundle exec pod install` prints the patch
message, and
`Pods/SwiftAudioEx/Sources/SwiftAudioEx/QueueManager.swift:148` now
reads `items.count > 0`.
- I checked the patch logic on three versions of the file: unpatched
gets patched, already patched is left alone, and a changed line raises.
- Confirmed the same line is in SwiftAudioEx tag 1.0.0 and in 1.1.0.
- Podfile.lock is not changed. The local install showed the known RNTP
4.0.1→4.1.2 / SwiftAudioEx 1.0.0→1.1.0 drift, which I left out.
- I haven't rebuilt the native app with this patch. The simulator check
of the last-item case was done with the JS fix (#14649).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Oct 9, 2026
…14653)

## 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](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Oct 9, 2026
…ver (#14651)

Moves the play bar tracker, the now playing scrubber and the track-list
eq bars off JS-driven `Animated` and onto the native driver. JS-only, so
it can ship as an OTA.

## Why
All three used `useNativeDriver: false`, so every frame ran in JS and
committed through `setNativeProps`. On old arch that's cheap. On the new
architecture each one is a full `ShadowTree::commit`, and Reanimated 3's
commit hook clones the tree on every commit. It was half of the CPU
regression in #14648 (blocker 6): playing with the drawer open went from
~15% to ~50%.

## What changed
- `animateLinear` in `utils/animation.ts` runs a long linear animation
as 10 s native-driver segments. The native driver precomputes one frame
per 16 ms, which is why the old code avoided it for track-length
animations (the old comment about Hermes). Each segment starts from the
wall-clock position, so the bar can't drift after the app is
backgrounded.
- `TrackingBar` and `Slider` use it with the start position passed in
explicitly (from `seek` or `TrackPlayer.getProgress()`), instead of
continuing from whatever the JS value was. Seeking, dragging and pausing
behave the same. The scrubber's 100 ms tap-to-seek timing moved to the
native driver too, because a value can't mix drivers. Both stop the
chain on unmount.
- `AnimatedEqBars` animated `height`, which the native driver can't do.
Each bar is now a full-height bar slid down with `translateY` inside a
clip view with the same 2 px radius. The visible shape at every height
matches the old one (rounded top from the bar, rounded bottom from the
clip).

## Base
**Base: `chore/mobile-rn-0.81` (#14642), shipping in native release
1.5.188 (#14654).** Rebased 2026-10-08 after #14642 moved onto the
release branch. Merge this into #14642, not main. The svg
`LinearGradient` it imports is on main since 1.5.187, so it no longer
depends on #14642's code, but it ships with 1.5.188 so the new
native-driver animations get device-tested on the RC builds.

It doesn't overlap the iOS queue fix (#14649, on main): that change is
in `AudioPlayer.tsx` and `addTracksAroundCurrent.ts`, which this PR
doesn't touch.

## Measurements (iOS 26.5 simulator, Release, signed in, 30 s `top`
averages)
| Scenario | Old arch before → after | New arch before → after |
|---|---|---|
| Feed, playing | 17.1% → 12.9% | 50.4% → 15.3% |
| Now playing drawer open, playing | 15.3% → 13.7% | 50.6% → 15.6% |
| Background, playing | 9.5% → 12.2% | 12.2% → 14.5% |

New-arch "after" also includes #14648's svg and Lottie fixes, which
don't affect these screens. Old-arch "after" is the #14642 Release app
with this branch's JS bundle swapped in.

## Testing
- Simulator, both architectures: the bar and the scrubber advance at the
right rate on a 56-minute and a 3-minute track. Tap-to-seek to the
middle lands at 28:02 and keeps going. The play bar is at the right
position after 30 s in the background.
- `tsc` and `eslint` clean on the changed files.
- Not checked by eye: the eq bars. In album track lists, the
`TrackImage` children (eq bars, play icon overlay) don't show on either
architecture, before or after this change. That looks like an existing
bug and is out of scope here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Oct 9, 2026
## Summary

Version bump for native release **1.5.188**. Merging it starts the RC
and production native builds.

- `packages/mobile/package.json`: 1.5.187 → 1.5.188 (and the matching
`package-lock.json` entry)
- iOS `CFBundleShortVersionString`: 1.1.200 → 1.1.201
- Android `versionName`: 1.1.536 → 1.1.537

1.5.188 contains (all still on the old architecture):
- #14642: React Native 0.79.5 → 0.81.6, React 19.1.4 monorepo-wide
- #14647: RNTP 4.1.2 patch for bridgeless events and `Unit` module
methods (no change on old arch)
- #14651: playback progress and eq bars on the native driver
- #14652: react-native-svg 15.15.1 and the Lottie `shouldBeRecycled NO`
patch (no change on old arch)

1.5.189 is then only the new-architecture flag flip.

## Stack

```
main
└── chore/mobile-native-release-1.5.188   (this PR)
    └── chore/mobile-rn-0.81              #14642
        ├── fix/mobile-rntp-bridgeless    #14647
        │   └── fix/mobile-new-arch-prereqs  #14652
        └── fix/mobile-native-driver-progress  #14651
```

## Verification of the rebased stack (2026-10-08)
Built from a local, unpushed integration branch: #14652's tip (which
contains #14647) merged with #14651, on top of #14654.

| Check | Result |
|---|---|
| `npx tsc --noEmit -p packages/mobile` | pass |
| eslint on the touched files / `turbo run verify` lint | pass (1
existing warning in `HostRemixContestDrawer.tsx`) |
| `cd packages/mobile && npm test` | 9 suites, 34 tests pass |
| iOS Release, `generic/platform=iOS Simulator`, arm64,
`ENVFILE=.env.prod` | builds; Info.plist 1.1.201,
`RCTNewArchEnabled=false`; bundle carries 1.5.188 |
| Android `assembleProdRelease`, arm64-v8a, debug keystore | builds;
versionName 1.1.537 |
| iOS 26.5 sim, installed over the signed-in app, CodePush moved aside |
launch to feed; JS thread `com.facebook.react.JavaScript` (old arch) |
| Playback from the 2nd feed track | plays; play bar tracker moves |
| Auto-advance | seek to 2:43/2:45 on track 2, advanced to track 3 |
| Now playing drawer | opens, scrubber advances, tap-to-seek works |
| Profile tabs | swipe Tracks → Albums, tap Reposts |
| notifee/RNFB APNs token | a temporary console log (not committed,
rebuilt without it afterwards) printed an 80-byte lowercase APNs token
at startup |
| Android 16 emulator, `pm clear` | `Loading JS bundle from
"assets://index.android.bundle"`, "Legacy Architecture" warning, sign-up
and sign-in screens render |
| Android hardware Back | **not verified**: the emulator's system_server
was killed by its watchdog three times under host load, so Back never
got a clean run |
| JS errors on iOS | only `Could not cache profile images` (content node
timeout) |

## Merge order

1. **Wait until 1.5.187 is live in both stores.** Until then, any native
change on main would go into a 1.5.187 rebuild.
2. **Dispatch a production OTA from main before merging anything
below**, for every store version that can run main's JS:
   ```
gh workflow run mobile.yml --repo AudiusProject/apps --ref main -f
ota_channel=production
   ```
With main at 1.5.187 this publishes to `production/1.5.187`. **Do not**
dispatch `-f binary_version=1.5.186` or `1.5.185` from main any more:
main now carries #14627, which calls
`TurboModuleRegistry.getEnforcing('ReactNativeFs')` at import, so that
JS crashes 1.5.186 and older binaries. If 1.5.186 users still need a
fix, dispatch from a commit before 5c4ee7e.
3. **Land everything on main in one push.** Merge the stack into this
branch from the top down: #14652 into #14647, #14647 and #14651 into
#14642, #14642 into this branch. Then merge this PR into main. Any push
to main that leaves the version unchanged publishes an RC OTA to the
current binary's history, and RN 0.81 JS on a 0.79 binary will not
start. With one push that also changes the version, the version check
skips the OTA and starts the native builds. After that, OTAs route to
`1.5.188` histories.
4. **Device-test the RC builds before submitting:**
- cold launch on both platforms, old arch at runtime (iOS JS thread
`com.facebook.react.JavaScript`; Android "Legacy Architecture" warning)
   - background audio with the screen locked for more than 2 minutes
   - lock-screen and notification controls, Bluetooth, Chromecast
- auto-advance, including starting from the 2nd track of a list
(#14649/#14650)
- play bar and scrubber position: seek, drag, pause/resume, background
then foreground
   - first play does not pause itself (Lottie)
- push: token registered after the 1.5.187 → 1.5.188 update and taps
open the right screen (#14640)
   - Android hardware Back on a pushed screen and at the root
   - gradients and offline downloads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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