Skip to main content

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.

caution

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.