Skip to main content

TrackPlayer

The core playback API. Get the singleton with getTrackPlayer():

import { getTrackPlayer } from 'react-native-queue-player';

const player = getTrackPlayer();

All mutating methods return a Promise; get* methods are synchronous snapshots except getQueue() and getLookaheadCacheStatus(), which return a Promise; all on* methods return a disposer function — call it to unsubscribe.

Lifecycle​

configure​

configure(config: PlayerConfig): Promise<void>

Sets up the engine. Each call rebuilds from a clean slate (it runs an internal destroy() first), so call it once at app start. All PlayerConfig fields are optional:

FieldTypeNotes
httpHeadersRecord<string, string>Applied to every remote request (media, cache, artwork).
userAgentstringOverrides the default user agent.
autoRetriesnumberAutomatic retries on a transient error; clamped 0–5 (default 1). One attempt covers a blip; a failure that survives a rebuild is rarely cured by a second, and further attempts only delay the error the app needs to act on. retry() is the manual attempt after it.
retryBackoffMsnumberClamped 200–5000 (default 500).
networkTimeoutMsnumberAndroid only; clamped 1000–60000 (default 30000).
audioContentType'music' | 'speech'Default 'music'. Pinned at first configure.
audioCategoryOptions{ duckOthers?, mixWithOthers?, interruptSpokenAudio? }iOS audio-session behaviour.
voiceVocabularyDonationEnabledbooleaniOS only. Default false. Enables donateVoiceVocabulary, which is inert without it. Requires the com.apple.developer.siri entitlement — enabling it without the Siri capability terminates the app, and iOS gives the library no way to check. Pinned at first configure for the life of the process.
allowsExternalPlaybackbooleaniOS only. Default false: the device renders the audio and AirPlay carries it, so the lookahead cache, EQ, ReplayGain, crossfade and the visualizer keep working on every receiver. true lets AVPlayer hand the track URL to a receiver that supports external playback, such as an Apple TV or a Mac; disable the lookahead cache with setLookaheadCache({ enabled: false }) before queueing tracks, because a track queued while the cache is on plays from a loopback URL the receiver cannot reach. See AirPlay. Pinned at configure.
visualizationEnabledbooleanDefault true. Disable to skip the visualizer audio chain. Pinned at configure.
clampSeekToBufferedbooleanDefault false. Cap a seek target past the buffered position at that edge (local / fully-buffered tracks seek freely). See Buffer state.
skipToPreviousBehavior'previous' | 'restart-or-previous'Default 'previous' (skip-back always goes to the previous track). 'restart-or-previous' restarts the current track when it's past ~3 s, otherwise goes to the previous track — the standard music-player model. Local playback only; when set, canSkipPrevious stays true while a track is loaded. See Transport.
lookaheadCacheMaxSizeMbnumberDisk budget for the look-ahead cache in MB (default 100). Configure-time only — the cache size is fixed at construction. See Look-ahead cache.
lookaheadCacheEvictionPolicy'lru' | 'fifo'Cache eviction order: 'lru' (default) keeps recently-played tracks; 'fifo' keeps the newest-fetched. Configure-time only — changing it clears + rebuilds the cache. See Look-ahead cache.
progressUpdateIntervalMsnumberDefault 500. Interval between onProgress emissions in the foreground. 500 is the floor (the native sampler runs at 2 Hz; lower values clamp to 500). See Progress update rate.
backgroundProgressUpdateIntervalMsnumberDefault = progressUpdateIntervalMs. Reduced onProgress interval while the app is backgrounded (e.g. 10000). Milestones + lock-screen updates are unaffected. See Progress update rate.
enableDiagnosticLogsbooleanDefault false. Writes the library's diagnostic lines to the device's system log so they can be exported from a TestFlight or App Store build. iOS only today. See Diagnostic logs.

destroy​

destroy(): Promise<void>

Tears down the engine, audio session, and observers. Idempotent.

Queue​

The queue is a flat, app-driven list; the library does not persist it.

setQueue(tracks: TrackItem[], startAtIndex?: number): Promise<void>
addToQueue(tracks: TrackItem[], insertBefore?: number): Promise<void> // appends when insertBefore omitted
removeFromQueue(indices: number[]): Promise<void>
moveInQueue(fromIndex: number, toIndex: number): Promise<void>
getQueue(): Promise<TrackItem[]>
clearQueue(): Promise<void>

TrackItem​

Only url is required. Every other field is optional metadata. id is provided for your convenience — it's echoed back to you in remote-command callbacks and voice-query results — so you don't need to supply one, and duplicate or missing ids are fine.

type TrackItem = {
url: string; // required — http(s):// or file://
id?: string; // optional; echoed back to you in callbacks + voice results
title?: string;
artist?: string;
album?: string;
duration?: number; // seconds
artworkUrl?: string;
extras?: Record<string, string>; // opaque passthrough
};

:::caution iOS does not follow http: → https: redirects Give the player the URL it should actually load. On iOS, AVFoundation refuses a redirect that changes protocol, and offers no option to permit one, so a cleartext http:// URL whose server answers 301/308 with an https:// location fails to load. Android follows the redirect and plays normally, so this shows up as an iOS-only failure on a queue that works elsewhere.

Resolve it on the server: serve the final https:// URL to the player rather than a URL that upgrades. Cleartext http:// also needs an App Transport Security exception in your app before iOS will make the request at all, so https:// end to end is the path of least resistance. :::

Transport​

play(): Promise<void>
pause(): Promise<void>
stop(): Promise<void>
retry(): Promise<void> // re-attempt the current track, from any state
seekTo(position: number): Promise<void> // seconds from the start of the current track
skipToIndex(index: number): Promise<void>
skipToNext(): Promise<void>
skipToPrevious(): Promise<void>

skipToPrevious behaviour depends on skipToPreviousBehavior. By default ('previous') it always goes to the previous track (a no-op at the first track under off / track repeat; wraps under queue). With 'restart-or-previous' it restarts the current track (seeks to 0) when the position is past ~3 s, otherwise goes to the previous track — the standard music-player model. It is native and applies to every surface (in-app, lock screen, Bluetooth, Android Auto, CarPlay); the restart is a seek, so it does not fire onTrackChange. Local playback only — during cast, Previous routes to the receiver. When enabled, canSkipPrevious stays true while a track is loaded, so the Previous control never greys out (even on the first track).

The player automatically retries a transient error once (see autoRetries); if that recovers, playback continues and the consumer sees nothing. If it does not, onError fires and retry() is the manual attempt behind a "tap to retry" button — the user left WiFi coverage so the automatic attempt failed, came back in range, and taps retry.

A track whose error has been reported stops the queue on itself. The library does not skip past it. currentTrackIndex stays on the failing track, the state stays error, and nothing advances, wraps or resumes until something asks for playback again — play() and retry() both restart the track, and a skip, a seek or a fresh queue move on from it. This is deliberate: skipping a broken track silently hides it from the listener, and a queue where several tracks are broken would otherwise walk the whole queue — or, under repeat-all, walk it forever. A consumer that prefers skip-on-error builds it from onError:

player.onError(async () => {
const i = await player.getCurrentTrackIndex();
const q = await player.getQueue();
if (i + 1 < q.length) await player.skipToIndex(i + 1);
});

onError fires once per failure you can act on. 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 — it reports when it becomes current and is attempted for real. Without that, a source the player keeps re-attempting (a server answering a ranged request with a whole body makes AVFoundation do exactly that) reports under alternating error codes, hundreds of times, for one broken track.

retry() always attempts, whatever state the player reports. It is not gated on the player having reported an error: a request to retry comes from someone who can hear that something is wrong, and the case where the reported state is itself wrong — a wedged decoder still claiming to play — is exactly when the button gets pressed. Retrying healthy playback rebuilds the current track and resumes at its position, so it is briefly audible; that is the accepted cost of never refusing a deliberate request. It is a no-op only when there is no current track, which is genuinely nothing to retry. A manual retry also hands the track back the allowance autoRetries configures, so whatever fails next still gets its automatic attempt — and a consumer who set autoRetries: 0 is still given none. During cast it forwards a play to the receiver instead: nothing is rebuilt locally and the automatic allowance is not touched.

Seeking is guarded by seekability: seekTo (and a lock-screen interval skip) is a no-op on a genuinely unseekable track — a live stream, or a variable-bitrate MP3 with no seek index — rather than resetting it to the start. Check getIsSeekable() to grey out a scrubber. The lib enables constant-bitrate seeking, so CBR MP3 / ADTS / AMR streams (common for transcoded audio) are seekable and expose an estimated duration.

Playback mode​

setPlaybackMode / getPlaybackMode​

setPlaybackMode(mode: PlaybackMode): Promise<void>
getPlaybackMode(): PlaybackMode
type PlaybackMode = {
kind: 'gapless' | 'crossfade';
crossfadeDurationMs?: number; // required when kind === 'crossfade'
};

For crossfade, crossfadeDurationMs must be within crossfadeDurationBounds — { minMs: 1000, maxMs: 12000, stepMs: 500 } (1–12 s, in 500 ms steps):

import { crossfadeDurationBounds } from 'react-native-queue-player';

await player.setPlaybackMode({ kind: 'crossfade', crossfadeDurationMs: 4000 });
await player.setPlaybackMode({ kind: 'gapless' });

Playback speed & pitch correction​

setPlaybackSpeed(rate: number): Promise<void> // playback rate; min 0.25
getPlaybackSpeed(): number
setPitchCorrectionMode(mode: PitchCorrectionMode): Promise<void>
getPitchCorrectionMode(): PitchCorrectionMode

setPlaybackSpeed changes the playback rate. PitchCorrectionMode controls how pitch behaves when the rate is not 1.0:

type PitchCorrectionMode = 'none' | 'voice' | 'music';
ModeBehaviour
nonePitch follows the rate (chipmunk / slow-down effect).
voicePitch preserved, speech-tuned. Default.
musicPitch preserved, music-tuned (transient-preserving).

The mode persists until you change it and is auto-bypassed at 1.0×, so there is no pitch processing at normal speed.

Volume, repeat, shuffle​

setVolume(volume: number): Promise<void> // 0.0 – 1.0
getVolume(): number
setRepeatMode(mode: RepeatMode): Promise<void> // 'off' | 'queue' | 'track'
shuffleQueue(): Promise<ShuffleResult> // one-shot shuffle; resets to index 0

ShuffleResult is { tracks: TrackItem[]; currentIndex: number; currentTrack?: TrackItem }.

Remote controls (lock screen / notification skip)​

setRemoteControls(options: RemoteControlOptions): Promise<void>
getRemoteControls(): RemoteControlOptions

interface RemoteControlOptions {
skipMode: 'track' | 'interval'; // default 'track'
forwardJumpInterval: number; // seconds, default 15
backwardJumpInterval: number; // seconds, default 15
}

Choose what the lock-screen / notification / CarPlay skip controls do:

  • 'track' (default) — previous / next track.
  • 'interval' — jump forward / back within the current track by forwardJumpInterval / backwardJumpInterval seconds. The seek is performed natively.

Applies immediately, so you can let the user pick their preferred controls at runtime. The interval is a free number of seconds; a common in-app picker offers 10 / 15 / 30 / 45 / 60. On iOS the two modes map to nextTrack/previousTrack vs skipForward/skipBackward (with the interval as the preferredIntervals); on Android they swap the notification's back/forward buttons.

State (synchronous reads)​

getState(): PlayerState // 'none'|'loading'|'buffering'|'playing'|'paused'|'ended'|'error'
getCurrentTrackIndex(): number // -1 when empty / nothing selected yet
getCurrentTrackSource(): TrackSource | undefined // 'local' | 'cached' | 'streaming'
getPlaybackSpeed(): number
getVolume(): number
getCanSkipNext(): boolean
getCanSkipPrevious(): boolean
getSkipCapability(): SkipCapability // { canSkipNext, canSkipPrevious }
getSleepTimer(): SleepTimerState // { active, endsAtEpochMs?, remainingSeconds?, endOfTrack }
getBufferState(): BufferState // 'empty' | 'buffering' | 'stalled' | 'full'
getIsSeekable(): boolean // false for a genuinely unseekable stream
isFullyBuffered(): boolean // whole track downloaded

getCanSkipPrevious() / getSkipCapability() reflect skipToPreviousBehavior: with the default 'previous' they follow the queue (false at the first track under off / track repeat); with 'restart-or-previous', canSkipPrevious is true whenever a track is loaded. Subscribe to onSkipCapabilityChange (or the useCanSkip hook) for live updates.

Events​

Each returns a disposer:

const off = player.onStateChange((state, reason) => { /* … */ });
// later:
off();
EventCallback signature
onStateChange(state: PlayerState, reason: StateChangeReason) => void
onTrackChange(track: TrackItem | undefined, index: number, reason: TrackChangeReason, transitionGapMs: number | undefined) => void — transitionGapMs is the native-measured inter-track silence in ms, set only on a natural gapless auto-advance and undefined otherwise (a < 50ms regression-check seed). Always null-check it: crossfade and cast advances also report reason: 'auto-advance' but carry undefined
onProgress(progress: { position, duration, buffered }) => void (default ~2 Hz, seconds; rate is configurable — see Progress update rate)
onPlaybackMilestone(milestone: number, trackIndex: number) => void (fires once each at 25 / 50 / 75 / 90 % of a track — for scrobbling / "mark as played")
onError(error: PlaybackError) => void
onQueueEnd() => void — the player is paused with state 'ended'; a later skipToIndex / setQueue seats the track and waits for play(), and an addToQueue only extends the queue until you skip or seek
onQueueChange(delta: QueueChangeDelta, currentIndex: number, reason: QueueChangeReason) => void — the change, not the whole queue: a move is two indices, a removal is its positions, a shuffle is a permutation. Apply it to your copy with applyQueueDelta(prev, delta, reason), exported from the package root, or re-read with getQueue(). delta.revision increments per emit; a jump of more than one means an event was missed and the copy should be rebuilt. Does not fire on subscribe. In React, useQueue does all of this
onSkipCapabilityChange(capability: SkipCapability) => void (fires once on subscribe)
onSleepTimerChange(state: SleepTimerState) => void (fires once on subscribe, then on arm / clear / end-of-track conversion / fire)
onBufferStateChange(state: BufferState) => void ('empty' | 'buffering' | 'stalled' | 'full'; fires once on subscribe, then on change — native-driven, see Buffer state)
onFullyBufferedChange(fullyBuffered: boolean) => void (fires once on subscribe, then when the whole track finishes downloading — see Buffer state)

The reason on onStateChange and onTrackChange says why the change happened:

type StateChangeReason =
| 'user' // a play / pause / stop / skip you called
| 'system' // the OS or engine (e.g. a stall recovering)
| 'error' // playback failed — an onError also fired
| 'interruption' // an audio-session interruption (call, alarm)
| 'route-change' // an output-route change (e.g. headphones unplugged)
| 'queue-end' // the queue reached its end
| 'sleep-timer'; // the sleep timer paused playback

type TrackChangeReason =
| 'user-skip-next'
| 'user-skip-previous'
| 'user-skip-to-index'
| 'auto-advance' // natural end-of-track advance (crossfade and cast advances too)
| 'queue-replaced' // setQueue seated a new current track
| 'seek'
| 'crossfade-cancelled'; // a crossfade cancelled mid-fade (pause / stop / audio-focus loss /
// queue mutation). Fires with the OUTGOING (track, index) so you can
// revert UI that flipped to the incoming on the prior 'auto-advance'
// fade-start fire. Crossfade-only.

PlaybackError carries a lib-standardized code for branching plus the raw native details:

interface PlaybackError {
code: PlaybackErrorCode; // cross-platform code (below) — drives consumer branching
message: string; // lib-mapped human-readable summary, consistent across platforms
fatal: boolean; // fatal errors stop playback; non-fatal ones let the player retry
url: string; // source URL of the queue item that failed
queueItemId: string; // lib-generated id of the failing queue item ('' if none active)
nativeCode: number; // NSError.code (iOS) / PlaybackException.errorCode (Android)
nativeDomain: string; // e.g. 'AVFoundationErrorDomain' (iOS) / 'Media3:ERROR_CODE_…' (Android)
nativeMessage: string; // raw native message (query strings stripped to avoid leaking tokens)
}

code is one of: 'TIMEOUT', 'CONNECTION_LOST', 'NETWORK_UNREACHABLE', 'HTTP_ERROR', 'AUTH_ERROR', 'CLEARTEXT_NOT_PERMITTED', 'FILE_FORMAT_NOT_RECOGNIZED', 'DECODER_NOT_FOUND', 'DECODING_FAILED', 'SOURCE_UNREACHABLE', 'CANNOT_OPEN', 'UNKNOWN'.

QueueChangeDelta describes one mutation; applyQueueDelta is the reference way to apply it, and useQueue does so for you.

type QueueChangeReason = 'set-queue' | 'add' | 'remove' | 'move' | 'clear' | 'shuffle';

interface QueueChangeDelta {
revision: number; // increments once per emit; a jump of more than one means an event was missed
length: number; // queue length after the mutation
inserted: TrackItem[]; // tracks now at [insertedAt, insertedAt + inserted.length); 'add' and 'set-queue' only
insertedAt: number; // -1 when nothing was inserted
removed: number[]; // positions removed, ascending, as they were before the removal
movedFrom: number; // 'move' only, else -1
movedTo: number; // 'move' only, else -1
order: number[]; // 'shuffle' only: order[i] is the position the track now at i held before
}

In React, prefer the hooks — they manage subscribe/unsubscribe for you.

Progress update rate​

onProgress fires at a fixed 2 Hz (500 ms) by default. Two configure options tune the rate:

configure({
progressUpdateIntervalMs: 500, // foreground (default 500; 500 is the floor)
backgroundProgressUpdateIntervalMs: 10000, // ~0.1 Hz while backgrounded (default = foreground)
});
  • The throttle applies to the JS onProgress fan-out only. The native sampler keeps running, so onPlaybackMilestone (scrobbling) and the lock-screen / now-playing position are unaffected — reducing the background rate does not delay milestones.
  • 500 ms is the floor: the native sampler runs at 2 Hz, so a value below 500 clamps to 500 (there is no faster-than-2 Hz foreground rate). Non-positive values fall back to the default. There is no upper limit — a large background value (e.g. 60000) is a valid near-paused rate.
  • Effective rates snap to multiples of the 500 ms sampler tick: an interval emits on the nearest tick at or before it, so e.g. 700 still yields ~2 Hz and 1000 yields ~1 Hz.
  • Applied at configure(); a re-configure() updates the live rate with no engine rebuild. Use backgroundProgressUpdateIntervalMs to stop off-screen re-renders while the app is backgrounded without hand-rolling app-state suppression.

Now Playing format​

getNowPlayingFormat(): NowPlayingFormat | null
onNowPlayingFormatChange(callback: (format: NowPlayingFormat | null) => void): () => void

NowPlayingFormat reports the active track's codec, container, mimeType, sampleRate, channelCount, bitrate, durationSec, ReplayGain metadata (replayGainTrackDb, replayGainAlbumDb, replayGainActive, …), and platform extras under ios (NowPlayingFormatIosExtras: bitsPerChannel) and android (NowPlayingFormatAndroidExtras: peakBitrate, codecs, pcmEncoding). It is null until the format resolves and re-fires on every track change.

codec (AudioCodec) is one of 'aac' | 'mp3' | 'flac' | 'alac' | 'opus' | 'pcm' | 'unknown'. container (AudioContainer) is one of 'mp4' | 'mp3' | 'flac' | 'ogg' | 'wav' | 'aac-adts' | 'hls' | 'unknown' — 'mp4' covers .m4a / .m4b / .m4r and audio-only .mp4, and 'hls' is the .m3u8 playlist container, not a file format.

Every field is best-effort and individually nullable — the player reports what the source actually declares, and invents nothing.

:::note bitrate is not always available

bitrate is the encoded average in bits per second. Where it comes from depends on what the format declares:

localremote
MP3, AAC, and other formats that declare a rate✅✅ where the source declares one
FLAC✅ derived✅ where the server declares one, else null

FLAC's STREAMINFO header carries a sample rate, channel count and total sample count, but no bitrate — the format simply does not declare one, and it is variable-rate, so there is nothing to read. For a local file the library derives the average from the file's size and its duration. Remotely there is no size to derive from without an extra request, and the library does not issue one to fill in a display field, so the only source is what the server itself declares; expect null from a server that declares nothing.

A derived figure is the average over the whole file, so it includes container headers and embedded artwork — typically a few kbps above the audio-only rate.

:::

ReplayGain​

setReplayGainMode(mode: ReplayGainMode): Promise<void> // 'off' | 'track' | 'album' (default 'off')
getReplayGainMode(): ReplayGainMode

Applies loudness normalization to ReplayGain-tagged media. Whether it is currently active for the playing track is reported by NowPlayingFormat.replayGainActive.

Values are read from the file automatically. A track that carries no tag for the selected mode plays unchanged — selecting album on a file with only a track gain applies nothing rather than falling back.

Supplying your own values​

If your server already knows a track's ReplayGain, put it on the TrackItem:

{
url: 'https://…/track.flac',
replayGainTrackGain: -7.85, // dB
replayGainTrackPeak: 0.98, // linear, 1.0 = digital full scale
replayGainAlbumGain: -6.05,
replayGainAlbumPeak: 0.99,
}

This is worth doing when a server strips ReplayGain tags while transcoding but still reports the measured values through its API — the tags are gone from the bytes you receive, so supplied values are the only source.

Supplying any one of the four fields means the library uses only what you supplied for that track, and ignores whatever the file contains — including for the mode you said nothing about. Supplying a track gain alone therefore leaves album mode with nothing to apply. The rule is deliberate: if you wanted the file's own tags, supply nothing and let detection run.

Clip protection uses the peak: the applied gain is capped at 1/peak so a boost cannot clip. When no usable peak is available the gain is capped at unity, so a positive gain is suppressed rather than applied unprotected. Supplying a gain without its peak is therefore safe, but a boost will not take effect until a peak is known. Peaks are reported back as null when absent — the library never invents one.

Sleep timer​

setSleepTimer(seconds: number): Promise<void> // pause after a fixed duration
setSleepTimerToTrackEnd(): Promise<void> // pause when the current track finishes
clearSleepTimer(): Promise<void>
getSleepTimer(): SleepTimerState
onSleepTimerChange(callback: (state: SleepTimerState) => void): () => void

The sleep timer pauses playback (it never stops or clears the queue, so tapping play resumes exactly where it left off). It runs on a native wall-clock deadline, so it keeps counting down whether playback is playing or paused, and survives backgrounding.

SleepTimerState:

interface SleepTimerState {
active: boolean;
endsAtEpochMs?: number; // wall-clock deadline; absent when inactive or awaiting track end
remainingSeconds?: number; // absent when inactive or awaiting track end
endOfTrack: boolean; // true while waiting for the track end; flips false on conversion to a countdown
}

Behaviour:

  • Fixed duration — setSleepTimer(15 * 60) pauses in 15 minutes. Pass 0 (or a non-positive value) to clear.
  • End of track — setSleepTimerToTrackEnd() pauses when the current track finishes. endsAtEpochMs / remainingSeconds are absent while endOfTrack is true; once the track enters the final-minute window the timer converts to a concrete countdown — endOfTrack flips to false and endsAtEpochMs / remainingSeconds appear.
  • 60-second tail rule — if a fixed-duration deadline lands with 60 seconds or less left on the current track, the pause is deferred to that track's natural end rather than cutting it off mid-play.
  • 10-second fade — playback fades out linearly over the final 10 seconds before the pause. The pre-fade volume is restored on pause, clear, or re-arm, so getVolume() stays truthful.
  • The pause emits onStateChange with reason 'sleep-timer'.

Buffer state​

getBufferState(): BufferState // 'empty' | 'buffering' | 'stalled' | 'full'
onBufferStateChange(callback: (state: BufferState) => void): () => void

A discrete buffer state for the current track, sourced from the platform's native buffer signals (iOS AVPlayerItem playback-buffer KVO; Android Media3 STATE_BUFFERING / STATE_READY) rather than the ~2 Hz progress tick — so a loading / stall indicator reacts immediately. onBufferStateChange fires once on subscribe with the current value, then only on an actual change.

  • 'full' — enough is buffered to play smoothly from the current position. Hide the spinner.
  • 'buffering' — loading, and playback has not started yet for this track (the initial buffer, even when play was requested), or loading while not actively trying to play. The expected, non-urgent loading case.
  • 'stalled' — playback had started and is now rebuffering mid-play (it ran out of data but wants to continue). This is where a spinner interrupts audio the user was already hearing.
  • 'empty' — nothing is loaded to play (before the first track loads / after teardown).

'full' describes playback health, not that the whole track is downloaded — for buffered-ahead length in seconds read onProgress's buffered field. In React, use useBufferState().

Buffer state tracks the local engine. During an active Cast session the local engine is paused, so getBufferState() reports the local buffer, not the receiver's — it is not meaningful while casting.

Fully buffered (whole track downloaded)​

isFullyBuffered(): boolean
onFullyBufferedChange(callback: (fullyBuffered: boolean) => void): () => void

Distinct from BufferState 'full' (which is "enough buffered to keep up"): isFullyBuffered() is true only once the entire track has downloaded and there is nothing left to transfer. On iOS it's the loaded range reaching the duration (or isPlaybackBufferFull for an unknown-length progressive source); on Android it's bufferedPosition reaching the (possibly CBR-estimated) duration. A live / unknown-length stream that never completes is never fully buffered. Use it to push a buffered bar to 100% or enable a full-length scrubber. In React, use useFullyBuffered().

Clamp seeks to the buffered range (opt-in)​

configure({ clampSeekToBuffered: true }); // default false

Off by default. When enabled, a seek target past the current buffered position is capped at that buffered edge, so a scrub doesn't overshoot into a re-buffer. A local or fully-buffered track reports buffered >= target, so nothing is capped and a seek reaches the end freely. This applies to seekTo and to a lock-screen / notification interval skip-forward (see remote controls).

This is separate from the always-on seekability guard above (which no-ops seeks on genuinely unseekable tracks). The clamp is a UX opt-in for seekable streams — it trades "seek anywhere, wait for re-buffer" for "seek only within what's downloaded". It uses only lib-observable facts (buffered position) and makes no assumption about any specific server.