Upgrading to v2
v2 raises the iOS minimum and changes three public surfaces. Each of the three was a synchronous or oversized call on a hot path; all are mechanical to update.
If you use the React hooks (useQueue, useActiveTrack, useLookaheadCache),
most of this is already handled for you — skip to
Behaviour changes.
iOS 16 is now the minimum
The library's iOS deployment target moves from 15.1 to 16.0, and the
optional QueuePlayer/Chromecast subspec from 15.0 to 16.0 with it. AVFoundation
lifts the main-thread requirement on AVPlayer and AVPlayerItem at iOS 16, and
the player relies on that.
Your app's own deployment target has to be 16.0 or higher — CocoaPods refuses to resolve the pod against a lower one:
The platform of the target `YourApp` (iOS 15.1) is not compatible with
`QueuePlayer`, which has a minimum requirement of iOS 16.0.
Raise it in ios/Podfile (platform :ios, '16.0') for bare React Native, or via
expo-build-properties
for Expo:
["expo-build-properties", { "ios": { "deploymentTarget": "16.0" } }]
Apps that still ship to iOS 15.x cannot adopt v2. Android is unaffected —
minSdk stays at 24.
getQueue() returns a Promise
// v1
const queue = player.getQueue();
// v2
const queue = await player.getQueue();
It marshalled the whole queue across the bridge on the JS thread, which is costly exactly when the queue is large. Each platform now reads it on the thread that owns the queue (the player's serial queue on iOS, main on Android), so the array is internally consistent.
getQueue() and getCurrentTrackIndex() are still not atomic, and awaiting
the first widens the gap rather than closing it — the index can name a position
in a queue you no longer hold. Subscribe when you need both:
// Delivered together, under one mutation.
player.onTrackChange((track, index) => { /* … */ });
player.onQueueChange((delta, currentIndex, reason) => { /* … */ });
getLookaheadCacheStatus() returns a Promise
// v1
const status = player.getLookaheadCacheStatus();
// v2
const status = await player.getLookaheadCacheStatus();
Counting fully-cached entries walks the cache — a filesystem check per entry on iOS, and a lock per key on Android. As a synchronous getter that ran on the JS thread.
JSON.stringify(player.getLookaheadCacheStatus()) still compiles in v2 and
silently produces {}. TypeScript will not flag it. Search your codebase for
this call and confirm each site awaits.
onQueueChange delivers a delta
// v1
player.onQueueChange((queue, currentIndex, reason) => {
setQueue(queue);
});
// v2
import { applyQueueDelta } from 'react-native-queue-player';
player.onQueueChange((delta, currentIndex, reason) => {
setQueue((prev) => applyQueueDelta(prev, delta, reason));
});
The old payload sent every TrackItem on every mutation, so dragging one row
re-sent the whole queue. The delta describes the change instead: a move is two
numbers, a removal is its positions, a shuffle is a permutation of integers.
applyQueueDelta is exported as the reference implementation — you do not have
to write the switch yourself. If you would rather re-read than track a copy,
await player.getQueue() still works.
Seeding
onQueueChange does not fire on subscribe. Read the queue once when you
subscribe, then apply each delta:
let queue = await player.getQueue();
const off = player.onQueueChange((delta, _index, reason) => {
queue = applyQueueDelta(queue, delta, reason);
});
Detecting a missed event
A delta only makes sense against the copy it was computed from. Every delta
carries a revision that increments by one per emit; if it skips, rebuild from
getQueue() rather than applying the delta. Length cannot substitute — move,
shuffle and a same-size set-queue all preserve it.
if (!haveQueue || (lastRevision !== 0 && delta.revision !== lastRevision + 1)) {
queue = await player.getQueue();
haveQueue = true;
} else {
queue = applyQueueDelta(queue, delta, reason);
}
lastRevision = delta.revision;
haveQueue matters as much as the revision check. A delta that arrives before
your seed resolves has nothing correct to apply to, and applying it anyway gives
a silently wrong queue — or, for shuffle, an array of holes. There is no way
to anchor a revision to a getQueue() result, so re-read on the first delta
after seeding rather than trying to validate it.
shuffle order
order[i] is the position the track now at i held before the shuffle.
Reading it the other way round produces the inverse permutation and renders a
scrambled queue.
Behaviour changes in 2.1
A failing track stops the queue instead of being skipped. Before 2.1 a
track whose error was fatal — or whose automatic attempt was spent — was
consumed by the player and the queue moved on to the next one, rebuilt and
resumed. With several unplayable tracks that walked the whole queue, and under
repeat-all it never stopped. The queue now stays on the failing track in the
error state until something asks for playback again; play() and retry()
both restart it, and a skip, a seek or a fresh queue move on.
If your app relied on the old behaviour to step past a broken track, that is
four lines against onError — see the error-handling notes in the
TrackPlayer API.
play() now restarts a track the queue has stopped on. It previously
required the retry budget to be spent, which a fatal failure never spends, so
pressing play on a failed track did nothing on either platform.
onError no longer repeats for the same failure. A track that has already
reported stays quiet until something asks for playback again, and a track the
listener has not reached does not report at all.
Android: playing a track no longer writes it to the lookahead cache. The
playback path reads through the cache and never writes to it, so only the
prefetcher fills it. A track played straight through is not cached afterwards,
and a partly-prefetched track is not completed by playing it —
getLookaheadCacheStatus().tracksFullyCached and a track's cached source
follow that. This is what keeps a server's error envelope out of the cache
without putting a body check on the playback path, where refusing a response
wrongly would make the track unplayable. iOS already behaved this way: its
read-through server only reads.
Android: a response that is not media is reported as
FILE_FORMAT_NOT_RECOGNIZED, not SOURCE_UNREACHABLE. The source was
reached; its payload is the problem. The code is also fatal, so no automatic
attempt is spent re-fetching a body that cannot become audio. This matches
what iOS already reported for the same response.
Behaviour changes
The hooks render empty on their first frame. useQueue, useActiveTrack
and useLookaheadCache used to seed themselves from a synchronous getter. Those
getters are now async, so each hook starts at its empty value and fills in once
the read resolves. Code that checked queue.length === 0 or indexed
queue[index] at mount to mean "no queue" will briefly see the empty state
instead — treat it as "not loaded yet".
onCacheStatusChange now fires once on subscribe with current status. In v1
a subscriber received nothing until the next prefetch completed, which for a
fully-cached queue or a consumer with prefetch disabled never happened. If you
seeded from getLookaheadCacheStatus() you can drop that call.
onTrackChange and onQueueChange deliberately do not fire on subscribe.
Anything that subscribes and then waits for the next change would otherwise
read that first fire as a real transition.
retry() no longer refuses. It used to be a no-op unless the player had
last reported an error, so a "tap to retry" button did nothing whenever the
reported state was stale — which is the case a user reaches for it in. It now
attempts from any state, and is a no-op only when there is no current track.
Retrying healthy playback rebuilds the current track and resumes at its
position, so it is briefly audible; if your UI shows the button unconditionally,
expect it to do something now.
autoRetries defaults to 1, not 3. A transient failure that survives one
rebuild is rarely cured by a second, and three attempts each behind a backoff
delay the onError your app needs to act on while the user waits on a track
that will not play. Pass autoRetries: 3 to configure() to keep the old
behaviour; the ceiling is still 5.
On iOS, getPlaybackMode() and getPitchCorrectionMode() no longer wait for
pending work. Both used to block behind whatever the player was doing, so a
fire-and-forget setPlaybackMode() followed immediately by the getter read the
new value. They now answer from their stored value rather than the player's own
queue, which keeps them off the critical path but means an un-awaited setter may
not be visible yet. Await the setter's promise if you need the getter to reflect
it. On Android getPitchCorrectionMode() answers on the main thread, behind
whatever the player is doing there, and getPlaybackMode() answers from its
stored value on the calling thread — so the same await-the-setter advice applies.
Reaching the end of the queue pauses the player. onQueueEnd fires, the
engine is paused and the state settles to 'ended'. In v1, on both platforms,
the engine was left running at the end, so a setQueue() or a skip issued
afterwards started playing on its own; both now seat the track paused and wait
for play(). An addToQueue(), a moveInQueue() or a repeat-mode change
after the end only changes the queue: the player stays on the finished track
in 'ended' until you skip or seek, and play() alone restarts nothing.
A disabled lookahead cache is not read. On Android in v1, setLookaheadCache({ enabled: false }) stopped prefetching but playback still read (and wrote) the
disk cache, so a track fetched earlier played with no request. Disabled now
means every read goes to the origin on both platforms; the entries on disk stay
and serve again once the cache is enabled. getCurrentTrackSource() reports
'cached' only while the cache is enabled.
The queue is emptied without an event by destroy() and configure(), and
configure() runs that teardown on every call. This is not new, but it matters
more now that you may be holding a mirror: re-read after either.
Not affected
getCurrentTrackIndex(), getState() and the other synchronous getters are
unchanged — they return a single value and do not walk the queue or the cache.