Events

Two APIs, one rule: a listener lives as long as the place you register it.

registerPlaybackSession

TrackPlayer.registerPlaybackSession(session:() => void | Promise<void>):voidv5.9.2+

Registers the app's playback session logic: a function that runs immediately at registration and is the process-scoped home for playback-critical code. Call once from `index.js`, before `AppRegistry.registerComponent`. Listeners registered inside it live as long as the JS runtime — they survive UI unmounts (app swiped away while music continues), unlike listeners registered in components. - Events reach listeners registered here in the foreground and in the background. On Android, background delivery arrives through the library's built-in forwarding; on iOS, ordinary emitter delivery continues during audio background playback. - If the app also registers registerBackgroundEventHandler, other background events go exclusively to that handler. Event.BrowseRefreshRequested always reaches session listeners on Android. - On Android, async listeners registered inside the session are awaited during background delivery with a guaranteed execution window. - Apps typically call this once from `index.js`, but multiple calls are allowed and each invokes its session function.

The home for your playback logic. Register it from index.js; the function runs at startup, and listeners you add inside it stay alive as long as JS is running. That includes while the app is backgrounded, and after the user swipes it away while music keeps playing. On Android, a listener that returns a promise is awaited in the background, so async work like saving progress gets up to five seconds to finish. Return the promise, as below, rather than discarding it with void: work that isn’t returned can be frozen until the app is next opened. Listeners are typed as PlayerEventListener, so returning a promise is fine under no-misused-promises. It works the same on iOS, Android, and web.

import TrackPlayer, { Event } from '@rntp/player';

// index.js — before AppRegistry.registerComponent
TrackPlayer.registerPlaybackSession(() => {
  TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ mediaId, position }) =>
    saveProgress(mediaId, position)
  );
  TrackPlayer.addEventListener(Event.PlaybackError, ({ code }) => {
    if (code === 'network') TrackPlayer.retry();
  });
});

addEventListener

TrackPlayer.addEventListener(event:T,listener:PlayerEventListener<T>):EmitterSubscription

Subscribe to player events (payload only; the event name is the first argument). - iOS: foreground and audio background (UIBackgroundModes `audio`). - Web: `destroy()` no longer removes listeners. Call the subscription's `remove()` before registering it again after setup. - Android: events go to registerBackgroundEventHandler when the app is not foregrounded; if no background handler is registered, they arrive here instead. Event.BrowseRefreshRequested always arrives here. - On Android, while the app is backgrounded, a listener that returns a promise is awaited: the library holds a wakelock until it settles, for up to five seconds (30 for BrowseRefreshRequested). Return the promise instead of discarding it with `void`, or the work can be frozen until the app is next opened. See PlayerEventListener. - Listeners registered inside components are removed when the UI unmounts (for example, when the app is swiped away while music continues); registerPlaybackSession is the recommended place for playback-critical listeners. - Remote control events are emitted only when setCommands uses `handling: 'js'` or `'hybrid'` for that command. - Android remote commands do not start a cold JS runtime. Without a usable runtime, JS-authority commands fall back to native behavior and emit no stale event later.

Subscribe to a single event. In components, use it for display state; the listener is removed when the component unmounts.

import TrackPlayer, { Event } from '@rntp/player';
import { useEffect } from 'react';

useEffect(() => {
  const sub = TrackPlayer.addEventListener(Event.PlaybackError, (event) => {
    console.error('Playback error', event.code, event.message);
  });
  return () => sub.remove();
}, []);

Event catalogue

EventValueDescription
Event.PlaybackStateChanged 'event.playback-state-changed'—
Event.IsPlayingChanged 'event.is-playing-changed'—
Event.MediaItemTransition 'event.media-item-transition'—
Event.MediaMetadataChanged 'event.media-metadata-changed'—
Event.MetadataReceived 'event.metadata-received'—
Event.PlaybackError 'event.playback-error'—
Event.PlaybackProgressUpdated 'event.playback-progress-updated'—
Event.QueueChanged 'event.queue-changed'—
Event.RemotePlay 'event.remote-play'—
Event.RemotePause 'event.remote-pause'—
Event.RemoteNext 'event.remote-next'—
Event.RemotePrevious 'event.remote-previous'—
Event.RemoteStop 'event.remote-stop'—
Event.RemoteSeek 'event.remote-seek'—
Event.RemoteSkipForward 'event.remote-skip-forward'—
Event.RemoteSkipBackward 'event.remote-skip-backward'—
Event.SleepTimerTriggered 'event.sleep-timer-triggered'—
Event.BrowseRefreshRequested v5.10.0+'event.browse-refresh-requested'Emitted when a car browser asks for the library root: the first eligible request per Media3 controller connection on Android, or a CarPlay scene connection on iOS. Refreshes apply to the whole tree. Android emits at most one refresh per 15 seconds across all car controllers; legacy media browsers can request another refresh after that interval on the same connection. Register the listener synchronously inside registerPlaybackSession, before its first await. Fetch fresh content and call setBrowseTree. Native never waits for the listener, completion is not acknowledged, and the host re-querying after the notification is not guaranteed. On Android the listener runs inside a Headless JS task with a 30 second ceiling, so timers and fetch work. Use a shorter app-owned timeout or AbortSignal and manage latest-wins semantics yourself. Android delivers this event to session listeners even when registerBackgroundEventHandler is registered. Development warning: "@rntp/player: Event.BrowseRefreshRequested arrived before any listener was registered; register it synchronously inside registerPlaybackSession so it runs inside the headless task"

Event payloads

PlaybackStateChanged

Fires when the playback state transitions.

FieldTypeDefaultDescription
state * PlaybackState——
TrackPlayer.addEventListener(Event.PlaybackStateChanged, ({ state }) => {
  console.log('New state:', state);
});

IsPlayingChanged

Fires when playing status toggles.

FieldTypeDefaultDescription
playing * boolean——
TrackPlayer.addEventListener(Event.IsPlayingChanged, ({ playing }) => {
  console.log('Playing:', playing);
});

MediaItemTransition

Fires when the active queue item changes. reason says why: auto when the previous item finished and playback moved on, seek for a skip or jump, repeat each time a repeat-one item loops, and playlistChanged when the queue was replaced or the current item removed.

FieldTypeDefaultDescription
item * MediaItem | null——
index * number——
reason * v5.12.0+MediaItemTransitionReason—Cause of the transition. Repeat-one loops keep the same item and index.
TrackPlayer.addEventListener(Event.MediaItemTransition, ({ item, index, reason }) => {
  if (reason === 'auto') console.log('Moved on to', item?.title, 'at index', index);
});

MediaMetadataChanged

Fires when the effective metadata of the currently active media item changes — the view that backs getActiveMediaItem() and the system Now Playing info (lock screen, Bluetooth, CarPlay / Android Auto). This is the event most apps want: it accounts for track transitions, explicit updateMetadata calls, and the auto-update path that merges incoming stream metadata into the queued item.

Effective metadata for the active item (getActiveMediaItem, Now Playing). Use MetadataReceivedEvent for raw per-frame stream metadata instead. v5.1.0+

FieldTypeDefaultDescription
title string——
artist string——
albumTitle string——
artworkUrl string——
genre string——
TrackPlayer.addEventListener(Event.MediaMetadataChanged, ({ title, artist }) => {
  console.log('Now playing:', title, artist);
});
The `useActiveMediaItem` hook subscribes to this event internally, so UI that uses the hook stays in sync with the lock screen automatically.

MetadataReceived

Fires for every metadata frame received off the wire (ICY blocks on Shoutcast/Icecast streams, ID3 frames in MP3/AAC streams, Vorbis comments, …). The payload reflects stream-derived fields only and is not merged with the queued media item — for the merged effective view, subscribe to MediaMetadataChanged.

Useful for analytics / scrobbling and for sanitization pipelines that want to filter stream metadata before writing it back (combine with autoUpdateMetadataFromStream: false and call updateMetadata yourself).

Fired for every metadata frame the audio stream pushes — ICY blocks (Shoutcast/Icecast radio), ID3 tags, Vorbis comments, QuickTime metadata. The payload reflects *stream-derived* fields only; user-supplied fields on the queued MediaItem (e.g. a static `genre` or `albumTitle`) are not merged in here. For the effective merged view that `getActiveMediaItem` returns (and that the lock screen / system Now Playing info reflects), subscribe to MediaMetadataChangedEvent instead. Typical consumers: analytics / scrobbling, sanitization pipelines that filter the raw stream before calling updateMetadata themselves (use with `PlayerConfig.autoUpdateMetadataFromStream: false`).

FieldTypeDefaultDescription
title string——
artist string——
albumTitle string——
artworkUrl string——
genre string——
TrackPlayer.addEventListener(Event.MetadataReceived, ({ title, artist }) => {
  console.log('Stream pushed:', title, artist);
});

PlaybackError

Fires when a playback error occurs.

FieldTypeDefaultDescription
code * PlaybackErrorCode——
message * string——
TrackPlayer.addEventListener(Event.PlaybackError, ({ code, message }) => {
  console.error(`[${code}] ${message}`);
  if (code === 'network') TrackPlayer.retry();
});

PlaybackProgressUpdated

Fires periodically during playback when progressSync is configured. Contains the same payload sent to the HTTP endpoint and saved natively.

FieldTypeDefaultDescription
mediaId * string——
position * number——
duration * number——
timestamp * number——
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ mediaId, position, duration, timestamp }) => {
  console.log(`${mediaId}: ${position}/${duration}s at ${timestamp}`);
});

SleepTimerTriggered

Fires when a sleep timer pauses playback.

FieldTypeDefaultDescription
type * 'time' | 'mediaItem'—Which sleep timer mode triggered: countdown or media-item boundary.
TrackPlayer.addEventListener(Event.SleepTimerTriggered, ({ type }) => {
  console.log(`Sleep timer fired (${type})`);
});

QueueChanged

Fires when the queue is modified. No payload.

TrackPlayer.addEventListener(Event.QueueChanged, () => {
  const queue = TrackPlayer.getQueue();
});

Remote control handlers

TrackPlayer.registerRemoteHandlers(handlers:RemoteHandlers):{ remove(): void; }v5.11.0+

Replaces the runtime's complete remote-handler registration and returns an idempotent remover. Removing an older registration never clears its replacement; removing the current one leaves no handlers and restores no previous registration. Register synchronously in registerPlaybackSession, before its first await, so handlers also exist in headless starts, and call setupPlayer there too. Registration before setup records intent; Android activates it after the runtime's controller finishes startup; iOS and web activate it after setup. Destroy suspends it until the next setup, retaining the requested capabilities and skip intervals. Registrations are never persisted. Per command, a handler takes precedence over legacy handling, then native handling. Handled presses emit no Remote event and bypass registerBackgroundEventHandler. At most 32 presses can be live, pending acceptance or already accepted, for five seconds after each press. Overflow runs the native operation without calling the handler. Android always uses the existing headless task, including in the foreground, and awaits returned promises. Its five-second execution window starts at native task start; expiry does not cancel JavaScript. A busy runtime can miss acceptance. performDefault preserves the original operation: Android system Previous may restart according to Media3's threshold and live-media rules; item navigation has no position-based restart threshold. iOS and web Previous keep their three-second restart threshold. Indexed queue selection, browse queue changes, RNTP API calls and receiver-side Cast controls remain outside interception. After an Android Auto browse selection installs its queue, the Play that follows reaches the play handler; CarPlay browse selection plays directly. iOS transport uses MPRemoteCommandCenter; web transport requires a browser MediaSession action. Unsupported browser actions are harmless no-ops. Requires the 5.11.0+ iOS, Android or web adapter; an unsupported platform implementation throws before retaining handlers. Full reload is supported; bootstrap changes under Fast Refresh require registration disposal or a full reload.

Presses from the lock screen, the notification, Bluetooth and headset keys, Android Auto and CarPlay all arrive as remote commands. By default the native player handles each one and no JavaScript runs. To run your own logic for a command, register a handler for it:

// inside registerPlaybackSession, before the first await
TrackPlayer.registerRemoteHandlers({
  next: () => TrackPlayer.skipToNext(),
  previous: (command) => command.performDefault(),
  play: async (command) => {
    if (await canPlay()) command.performDefault();
  },
});

A handler owns its command. The native action only runs if the handler calls performDefault(), so a handler that returns without calling it consumes the press. Commands without a handler keep their native behaviour. performDefault() runs the original native operation once, with the arguments captured at the press, within five seconds of it.

The keys are play, pause, stop, next, previous, seek (with command.position), skipForward and skipBackward (with command.interval). Buttons still come from setCommands: a handler doesn’t make a button appear, it decides what the button does.

Register handlers synchronously inside registerPlaybackSession so they also exist when the app starts in the background, for example from Android Auto with android.autoStartJs. On Android they only take over once setupPlayer() has run in the same runtime, so a background start needs setupPlayer() in the playback session as well. If you register handlers in a component instead, call remove() when it unmounts.

Calling registerRemoteHandlers again replaces the whole set, and the returned remove() clears it. Registrations are never saved: each JS runtime registers its own, and a press that arrives before the player has finished connecting runs natively.

A few limits:

  • At most 32 presses can be waiting on or held by handlers at once, each for five seconds. Presses beyond that run natively without calling the handler.
  • A handler that throws or rejects consumes the press.
  • For asynchronous work, make the handler async and await it before calling performDefault(), as in the play example above. On Android, a backgrounded app’s timers only run while the handler’s promise is pending, so work scheduled to run after the handler returns can miss the five-second window.
  • Picking an item from the queue, calls your app makes to TrackPlayer, and controls on a Cast receiver are not intercepted. Browse selection in the car loads its queue natively; in Android Auto the Play that follows goes to your play handler, while CarPlay plays the selection directly.
  • On Android, handlers run inside the background task even while the app is in the foreground.

Moving from handling: 'js'

handling: 'js' and 'hybrid' with the Remote* events below still work in 5.x, but they are deprecated and go away in 6.0. A listener becomes a handler:

// before
TrackPlayer.setCommands({ capabilities, handling: 'js' });
TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext());

// after
TrackPlayer.setCommands({ capabilities });
TrackPlayer.registerRemoteHandlers({ next: () => TrackPlayer.skipToNext() });

A few differences from the old setup. Handlers survive destroy() and setupPlayer(), while Android’s setupPlayer still resets legacy handling to native. System Previous/Next on Android keep running natively under legacy handling, as in 5.10, whereas a next or previous handler receives them. And on Android a play handler also owns restarting from the end of the queue: call command.performDefault() rather than TrackPlayer.play() if Play should start over after the last track.

Remote control events (deprecated)

These fire only when setCommands sets handling to 'js' or 'hybrid' for that command and no handler is registered for it. Use handlers in new code.

RemotePlay / RemotePause / RemoteNext / RemotePrevious / RemoteStop

All four fire with no payload.

RemoteSeek

FieldTypeDefaultDescription
position * number——

RemoteSkipForward

FieldTypeDefaultDescription
interval * number——

RemoteSkipBackward

FieldTypeDefaultDescription
interval * number——

Background event handler (advanced)

TrackPlayer.registerBackgroundEventHandler(factory:() => BackgroundEventHandler):void

Android only. Headless JS handler while the app UI is backgrounded. Normalizes Headless payloads to BackgroundEvent before invoking the handler. - Use registerPlaybackSession instead; session listeners returning a promise get the same guaranteed background execution window. This handler is kept for backwards compatibility. - iOS: no-op — use addEventListener (including audio background with UIBackgroundModes `audio`). - Register from `index.js` before `AppRegistry.registerComponent`. Handler receives BackgroundEvent; keep work under ~5s. - When no handler is registered, background events are delivered to addEventListener listeners instead. - Event.BrowseRefreshRequested always goes to session listeners, even when this handler is registered. - UI foreground on Android: events are delivered to addEventListener. - Only receives remote events when setCommands uses `handling: 'js'` or `'hybrid'` for that command; default `native` performs actions without invoking this handler. - Android remote commands do not start a cold JS runtime. A JS-authority command falls back to native behavior when no usable runtime exists, and its remote event is not replayed later.

Use registerPlaybackSession instead. Session listeners that return a promise are awaited in the background with a guaranteed execution window (the library holds a wakelock until they settle), so this handler offers nothing extra. It is kept for backwards compatibility.

If you register it, it takes over: while the app is backgrounded on Android, events go to this handler only, and your session listeners see them again when the app returns to the foreground. It is Android-only (no-op on iOS), and presses taken by remote handlers never reach it.

v5.3.0+: Android handlers receive BackgroundEvent (event.type + fields). See changelog if you previously used raw { event, payload }.
If registered, call it in app entry (e.g. index.js) before setupPlayer() and AppRegistry.registerComponent — not inside a component.
import TrackPlayer, { Event, type BackgroundEvent } from '@rntp/player';

// index.js — before AppRegistry.registerComponent
TrackPlayer.registerBackgroundEventHandler(() => async (event: BackgroundEvent) => {
  switch (event.type) {
    case Event.RemotePause:
      TrackPlayer.pause();
      break;
    case Event.PlaybackProgressUpdated:
      await TrackPlayer.updateMetadata(0, { title: `At ${event.position}s` });
      break;
  }
});
ende