/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ import { AppRegistry, Image, Platform, type EmitterSubscription, } from 'react-native'; import { TrackPlayer, emitter } from './trackPlayerModule'; import { type PlayerConfig, type RemoteControlConfig, type BrowseCategory, type BrowseItem, type MediaItem, type MediaUrl, type Progress, type RepeatMode, type PreloadOptions, DEFAULT_PLAYER_CONFIG, DEFAULT_ANDROID_PLAYER_CONFIG, DEFAULT_CACHE_CONFIG, } from './interfaces'; import { Event, type EventPayloadByEvent, type BackgroundEvent, } from './events'; import { type PlaybackState } from './events'; import { headlessDataToBackgroundEvent } from './backgroundEvents'; let isPlayerSetup = false; let hasWarnedIosBackgroundHandler = false; let hasWarnedWebBackgroundHandler = false; // Matches RFC 3986 URI schemes (e.g. `http:`, `file:`, `asset:`, // `content:`, `ipod-library:`, `data:`). const HAS_URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i; /** * Normalizes a single URL string into one of: * - `http(s)://...` — remote stream * - `file://...` — local file (absolute path; bare paths get the scheme prepended) * - `asset://` — platform-managed asset (iOS bundle resource, Android raw/drawable) * - any other scheme is passed through verbatim (e.g. `content://`, `data:`, `ipod-library://`) * * Native code only ever sees these canonical forms. */ function normalizeUrlString(raw: string): string { if (raw.length === 0) return raw; if (HAS_URI_SCHEME.test(raw)) return raw; if (raw.startsWith('/')) return `file://${raw}`; return `asset://${raw}`; } /** * Resolves a `require()`'d asset to a canonical URL string. * In dev this is usually `http://localhost:8081/...`; in release builds it * may be a bare path (iOS) or a bare asset name (Android), which we route * through `normalizeUrlString`. */ function resolveAssetNumber(asset: number): string { const resolved = Image.resolveAssetSource(asset); if (resolved?.uri == null) { throw new Error( `@rntp/player: Image.resolveAssetSource(${asset}) returned no URI. ` + `Make sure the asset is bundled (e.g. add the extension to metro's assetExts).` ); } return normalizeUrlString(resolved.uri); } function resolveUrl( url: MediaUrl ): string | { uri: string; headers?: Record } { if (typeof url === 'number') return resolveAssetNumber(url); if (typeof url === 'string') return normalizeUrlString(url); // Object form: `{ uri, headers?, bundle? }`. `uri` may itself be a // `require()` number; resolve it to a string first. const innerString = typeof url.uri === 'number' ? resolveAssetNumber(url.uri) : url.uri; // iOS-style bundle prefix: `{ uri: 'foo.mp3', bundle: 'Sounds' }` → // `asset://Sounds.bundle/foo.mp3`. Only meaningful for bare names; ignored // when `uri` already has a scheme. const prefixed = url.bundle && !HAS_URI_SCHEME.test(innerString) && !innerString.startsWith('/') ? `${url.bundle}.bundle/${innerString}` : innerString; const normalized = normalizeUrlString(prefixed); return url.headers ? { uri: normalized, headers: url.headers } : { uri: normalized }; } function resolveMediaItem(item: MediaItem): MediaItem { return { ...item, url: resolveUrl(item.url), ...(item.artworkUrl != null ? { artworkUrl: resolveUrl(item.artworkUrl) } : {}), }; } // ─── Background Events ────────────────────────────── export type BackgroundEventHandler = (event: BackgroundEvent) => Promise; // ─── Setup ─────────────────────────────────────────── /** * Merges user config with defaults. TS is the source of truth; native receives a full config. */ function normalizePlayerConfig(options: PlayerConfig) { const android = options.android ? { ...DEFAULT_ANDROID_PLAYER_CONFIG, ...options.android } : DEFAULT_ANDROID_PLAYER_CONFIG; const cache = options.cache ? { ...DEFAULT_CACHE_CONFIG, ...options.cache } : undefined; return { ...DEFAULT_PLAYER_CONFIG, ...options, contentType: options.contentType ?? DEFAULT_PLAYER_CONFIG.contentType, handleAudioBecomingNoisy: options.handleAudioBecomingNoisy ?? DEFAULT_PLAYER_CONFIG.handleAudioBecomingNoisy, autoUpdateMetadataFromStream: options.autoUpdateMetadataFromStream ?? DEFAULT_PLAYER_CONFIG.autoUpdateMetadataFromStream, audioMixing: options.audioMixing ?? DEFAULT_PLAYER_CONFIG.audioMixing, ...(cache ? { cache } : {}), ...(options.progressSync ? { progressSync: options.progressSync } : {}), android, }; } /** * Initializes the native player and applies initial options. * Must be called once before using any other API. * Defaults are defined in TS; native receives a full config. * * @remarks * - **Android**: Should be called while the app is in the foreground * - Calling twice will throw */ export function setupPlayer(options: PlayerConfig = {}): void { if (isPlayerSetup) { throw new Error( 'Player is already set up. setupPlayer() should only be called once.' ); } TrackPlayer.setupPlayer(normalizePlayerConfig(options)); isPlayerSetup = true; } /** * Android only. Headless JS handler while the app UI is backgrounded. Normalizes Headless * payloads to {@link BackgroundEvent} before invoking the handler. * * @remarks * - iOS: no-op — use {@link addEventListener} (including audio background with UIBackgroundModes `audio`). * - Register from `index.js` before `AppRegistry.registerComponent`. Handler receives {@link BackgroundEvent}; keep work under ~5s. * - UI foreground on Android: use {@link addEventListener} instead. * - Does **not** run for Android Auto or CarPlay — in-car controls use native {@link setCommands} handling only. * - Only receives remote events when {@link setCommands} uses `handling: 'js'` or `'hybrid'` for that command; default `native` performs actions without invoking this handler. */ export function registerBackgroundEventHandler( factory: () => BackgroundEventHandler ): void { if (Platform.OS === 'android') { AppRegistry.registerHeadlessTask('TrackPlayerServiceBridge', () => { const handler = factory(); return (data) => handler(headlessDataToBackgroundEvent(data)); }); return; } if (Platform.OS === 'web') { if (__DEV__ && !hasWarnedWebBackgroundHandler) { hasWarnedWebBackgroundHandler = true; console.warn( '@rntp/player: registerBackgroundEventHandler is Android-only. On web use addEventListener.' ); } return; } if (__DEV__ && !hasWarnedIosBackgroundHandler) { hasWarnedIosBackgroundHandler = true; console.warn( '@rntp/player: registerBackgroundEventHandler is Android-only. On iOS use addEventListener (works in background with UIBackgroundModes audio).' ); } } // ─── Events ────────────────────────────────────────── /** * Subscribe to player events (payload only; the event name is the first argument). * * @remarks * - iOS: foreground and audio background (UIBackgroundModes `audio`). * - Android: UI foreground; {@link registerBackgroundEventHandler} when backgrounded. * - Remote control events are emitted only when {@link setCommands} uses `handling: 'js'` or `'hybrid'` for that command. * - Android Auto and CarPlay do not invoke JS listeners; use native {@link setCommands} for in-car behavior. */ export function addEventListener( event: T, listener: EventPayloadByEvent[T] extends never ? () => void : (event: EventPayloadByEvent[T]) => void ): EmitterSubscription { return emitter.addListener( event, listener as (data: any) => void ) as EmitterSubscription; } // ─── Playback ──────────────────────────────────────── /** Start playback. */ export function play(): void { TrackPlayer.play(); } /** Pause playback. */ export function pause(): void { TrackPlayer.pause(); } /** Stop playback and reset position. Queue is preserved. */ export function stop(): void { TrackPlayer.stop(); } /** Seek to a position in seconds. */ export function seekTo(position: number): void { TrackPlayer.seekTo(position); } /** Seek by a relative offset in seconds (negative = rewind). */ export function seekBy(offset: number): void { TrackPlayer.seekBy(offset); } /** Skip to the next item in the queue. */ export function skipToNext(): void { TrackPlayer.skipToNext(); } /** * Skip to the previous item in the queue. * If playback is past ~3 seconds, restarts the current item instead. */ export function skipToPrevious(): void { TrackPlayer.skipToPrevious(); } /** Skip to a specific index in the queue. */ export function skipToIndex(index: number): void { TrackPlayer.skipToIndex(index); } /** * Re-prepares the current media item after a playback error. * No-op if the player is not in an error state. * Does not auto-play — call {@link play} after if needed. */ export function retry(): void { TrackPlayer.retry(); } /** Set the playback speed (1.0 = normal). */ export function setPlaybackSpeed(speed: number): void { TrackPlayer.setPlaybackSpeed(speed); } /** Set the player volume (0.0 to 1.0). */ export function setVolume(volume: number): void { TrackPlayer.setVolume(volume); } // ─── Queue ─────────────────────────────────────────── /** * Clears the queue, adds the media item, and resets position. * To begin playback, call {@link play} after this. */ export function setMediaItem(mediaItem: MediaItem): void { TrackPlayer.setMediaItem(resolveMediaItem(mediaItem)); } /** * Clears the queue, adds the media items, and resets position. * @param startIndex The index of the item to start from (default: 0). */ export function setMediaItems(mediaItems: MediaItem[], startIndex = 0): void { TrackPlayer.setMediaItems(mediaItems.map(resolveMediaItem), startIndex); } /** Append a media item to the end of the queue. */ export function addMediaItem(mediaItem: MediaItem): void { TrackPlayer.addMediaItem(resolveMediaItem(mediaItem)); } /** Append media items to the end of the queue. */ export function addMediaItems(mediaItems: MediaItem[]): void { TrackPlayer.addMediaItems(mediaItems.map(resolveMediaItem)); } /** Insert a media item at the given index. */ export function insertMediaItem(index: number, mediaItem: MediaItem): void { TrackPlayer.insertMediaItem(index, resolveMediaItem(mediaItem)); } /** Insert media items starting at the given index. */ export function insertMediaItems(index: number, mediaItems: MediaItem[]): void { TrackPlayer.insertMediaItems(index, mediaItems.map(resolveMediaItem)); } /** Remove the media item at the given index. */ export function removeMediaItem(index: number): void { TrackPlayer.removeMediaItem(index); } /** * Remove media items in the range [fromIndex, toIndex). * @param fromIndex Start of range (inclusive) * @param toIndex End of range (exclusive) */ export function removeMediaItems(fromIndex: number, toIndex: number): void { TrackPlayer.removeMediaItems(fromIndex, toIndex); } /** Clear all media items from the queue. */ export function clear(): void { TrackPlayer.clear(); } /** Clears the on-disk audio cache. For debugging; next playback will re-download. */ export function clearCache(): void { TrackPlayer.clearCache(); } /** * Preload a media item into the cache for instant playback later. * Works independently of the queue — any future play of the same URL * will be a cache hit. */ export function preload(item: MediaItem, options?: PreloadOptions): void { TrackPlayer.preload(resolveMediaItem(item), options?.duration ?? -1); } /** * Cancel an in-progress preload for the given media item. * Partial data remains in cache. */ export function cancelPreload(item: MediaItem): void { TrackPlayer.cancelPreload(resolveMediaItem(item)); } /** * Replace the media item at the given index. * May continue playback seamlessly if the URL hasn't changed. */ export function replaceMediaItem(index: number, mediaItem: MediaItem): void { TrackPlayer.replaceMediaItem(index, resolveMediaItem(mediaItem)); } /** Move a media item from one position to another. */ export function moveMediaItem(fromIndex: number, toIndex: number): void { TrackPlayer.moveMediaItem(fromIndex, toIndex); } /** * Update display metadata for a media item without interrupting playback. * Only provided fields are updated; omitted fields keep their current values. * Useful for livestreams/radio where metadata changes while the stream URL stays the same. */ export function updateMetadata( index: number, metadata: { title?: string; artist?: string; albumTitle?: string; artworkUrl?: MediaUrl; } ): void { const resolved = metadata.artworkUrl != null ? { ...metadata, artworkUrl: resolveUrl(metadata.artworkUrl) } : metadata; TrackPlayer.updateMetadata(index, resolved); } // ─── State Getters (sync) ──────────────────────────── /** Gets the current playback state. */ export function getPlaybackState(): PlaybackState { return TrackPlayer.getPlaybackState() as PlaybackState; } /** Whether the player is currently producing audio output. */ export function isPlaying(): boolean { return TrackPlayer.isPlaying(); } /** Gets position, duration, buffered position, and cached position in seconds. */ export function getProgress(): Progress { const result = TrackPlayer.getProgress(); return { position: result.position as number, duration: result.duration as number, buffered: result.buffered as number, cached: (result.cached as number) ?? 0, }; } /** Gets the current playback speed. */ export function getPlaybackSpeed(): number { return TrackPlayer.getPlaybackSpeed(); } /** Gets the current player volume (0.0 to 1.0). */ export function getVolume(): number { return TrackPlayer.getVolume(); } /** Gets the currently active media item, or null if none. */ export function getActiveMediaItem(): MediaItem | null { return TrackPlayer.getActiveMediaItem() as MediaItem | null; } /** Gets the index of the currently active media item, or null if none. */ export function getActiveMediaItemIndex(): number | null { return TrackPlayer.getActiveMediaItemIndex(); } /** Gets all media items in the queue. */ export function getQueue(): MediaItem[] { return TrackPlayer.getQueue() as MediaItem[]; } /** Gets the current repeat mode. */ export function getRepeatMode(): RepeatMode { return TrackPlayer.getRepeatMode() as RepeatMode; } /** Whether shuffle is enabled. */ export function isShuffleEnabled(): boolean { return TrackPlayer.isShuffleEnabled(); } // ─── Player Options ────────────────────────────────── /** * Update remote control configuration at runtime. * Use this to change which commands appear in the notification/lock screen * (e.g., disable skip buttons on a single-item queue). * * @remarks * Configures the **native** media session (lock screen, notification, Android Auto). * With `handling: 'native'` (default), actions run without JavaScript; on Android * config is persisted for the playback service. `handling: 'js'` / `'hybrid'` emit * remote events to `addEventListener` / `registerBackgroundEventHandler` on the phone * only — Android Auto and CarPlay do not run the JS runtime; use native handling * or native extensions for in-car custom behavior. */ export function setCommands(commands: RemoteControlConfig): void { TrackPlayer.setCommands(commands); } /** Set the repeat mode. */ export function setRepeatMode(mode: RepeatMode): void { TrackPlayer.setRepeatMode(mode); } /** Enable or disable shuffle. */ export function setShuffleEnabled(enabled: boolean): void { TrackPlayer.setShuffleEnabled(enabled); } // ─── Progress Sync ─────────────────────────────────── /** Update the HTTP headers used for progress sync POST requests. */ export function updateProgressSyncHeaders( headers: Record ): void { TrackPlayer.updateProgressSyncHeaders(headers); } // ─── Browse Tree ───────────────────────────────────── function resolveBrowseItem(item: BrowseItem): BrowseItem { return { ...item, ...(item.url != null ? { url: resolveUrl(item.url) } : {}), ...(item.artworkUrl != null ? { artworkUrl: resolveUrl(item.artworkUrl) } : {}), ...(item.children != null ? { children: item.children.map(resolveBrowseItem) } : {}), }; } /** * Set the media browse tree for Android Auto and CarPlay. * Each category becomes a top-level browsable folder (Android Auto) or tab (CarPlay); * its items can be playable tracks or browsable containers with children. * * When a user selects a playable item, the native player loads its siblings * as the queue and starts playback without JS intervention. * * @remarks * - On CarPlay, a maximum of 4 tabs are shown. If more than 4 categories are provided, * the first 3 become tabs and the rest are grouped under a "More" tab. * - Maximum nesting depth: 4 levels (matching CarPlay's navigation stack limit). * - Call again to update the tree (e.g. after loading new content) */ export function setBrowseTree(categories: BrowseCategory[]): void { const resolved = categories.map((cat) => ({ ...cat, items: cat.items.map(resolveBrowseItem), })); TrackPlayer.setBrowseTree(resolved); } // ─── Sleep Timer ───────────────────────────────────── /** * Sets a countdown sleep timer. Playback pauses after `seconds` of wall clock time. * The timer keeps counting even if playback is paused. * * @param seconds Duration in seconds before playback pauses. * @param options.fadeOutSeconds Gradually reduce volume over the last N seconds. * Clamped to `seconds` if it exceeds it. Defaults to 0 (no fade). * * @remarks Cancels any existing sleep timer (last one wins). */ export function sleepAfterTime( seconds: number, options?: { fadeOutSeconds?: number } ): void { TrackPlayer.sleepAfterTime(seconds, options?.fadeOutSeconds ?? 0); } /** * Pauses playback after the media item at `index` finishes playing. * * @param index Queue index of the target media item. Defaults to the active item. * * @remarks * - Cancels any existing sleep timer (last one wins). * - Index is absolute — queue mutations after setting may shift which item it points to. * - No fade-out support (no known time horizon). */ export function sleepAfterMediaItemAtIndex(index?: number): void { const resolvedIndex = index ?? getActiveMediaItemIndex() ?? 0; TrackPlayer.sleepAfterMediaItemAtIndex(resolvedIndex); } /** * Returns the current sleep timer state, or `null` if none is active. */ export function getSleepTimer(): | { type: 'time'; remainingSeconds: number; fadeOutSeconds: number; } | { type: 'mediaItem'; index: number; } | null { const result = TrackPlayer.getSleepTimer(); if (result == null) return null; return result as any; } /** * Cancels the active sleep timer. If a volume fade is in progress, * restores the original volume immediately. */ export function cancelSleepTimer(): void { TrackPlayer.cancelSleepTimer(); } // ─── Destroy ───────────────────────────────────────── /** * Tears down the player and releases all resources. * After calling this, you must call `setupPlayer()` again to use the player. */ export function destroy(): void { TrackPlayer.destroy(); isPlayerSetup = false; }