UNPKG

expo-media-control

Version:

Comprehensive media control module for Expo and React Native with background audio support, lock screen controls, and system integration

674 lines (602 loc) 19.9 kB
import { NativeModule, requireNativeModule } from 'expo'; // ============================================= // CUSTOM ERROR TYPES // ============================================= /** * Base error class for media control errors */ export class MediaControlError extends Error { constructor(message: string, public readonly code?: string, public readonly cause?: Error) { super(message); this.name = 'MediaControlError'; } } /** * Error thrown when validation fails */ export class ValidationError extends MediaControlError { constructor(message: string, public readonly field?: string) { super(message, 'VALIDATION_ERROR'); this.name = 'ValidationError'; } } /** * Error thrown when native module operations fail */ export class NativeError extends MediaControlError { constructor(message: string, code?: string, cause?: Error) { super(message, code, cause); this.name = 'NativeError'; } } /** * Error thrown when media controls are not enabled */ export class NotEnabledError extends MediaControlError { constructor(message: string = 'Media controls are not enabled') { super(message, 'NOT_ENABLED'); this.name = 'NotEnabledError'; } } // ============================================= // VALIDATION UTILITIES // ============================================= /** * Validates media metadata input */ function validateMetadata(metadata: any): asserts metadata is MediaMetadata { if (!metadata || typeof metadata !== 'object') { throw new ValidationError('Metadata must be an object', 'metadata'); } // Validate optional string fields const stringFields = ['title', 'artist', 'album', 'genre', 'date']; for (const field of stringFields) { if (metadata[field] !== undefined && typeof metadata[field] !== 'string') { throw new ValidationError(`${field} must be a string`, field); } } // Validate optional number fields const numberFields = ['duration', 'elapsedTime', 'trackNumber', 'albumTrackCount']; for (const field of numberFields) { if (metadata[field] !== undefined && typeof metadata[field] !== 'number') { throw new ValidationError(`${field} must be a number`, field); } if (metadata[field] !== undefined && metadata[field] < 0) { throw new ValidationError(`${field} must be non-negative`, field); } } // Validate artwork if (metadata.artwork !== undefined) { if (!metadata.artwork || typeof metadata.artwork !== 'object') { throw new ValidationError('artwork must be an object', 'artwork'); } if (typeof metadata.artwork.uri !== 'string' || metadata.artwork.uri.length === 0) { throw new ValidationError('artwork.uri must be a non-empty string', 'artwork.uri'); } if (metadata.artwork.width !== undefined && typeof metadata.artwork.width !== 'number') { throw new ValidationError('artwork.width must be a number', 'artwork.width'); } if (metadata.artwork.height !== undefined && typeof metadata.artwork.height !== 'number') { throw new ValidationError('artwork.height must be a number', 'artwork.height'); } } // Validate rating if (metadata.rating !== undefined) { if (!metadata.rating || typeof metadata.rating !== 'object') { throw new ValidationError('rating must be an object', 'rating'); } if (!Object.values(RatingType).includes(metadata.rating.type)) { throw new ValidationError('rating.type must be a valid RatingType', 'rating.type'); } if (typeof metadata.rating.value !== 'boolean' && typeof metadata.rating.value !== 'number') { throw new ValidationError('rating.value must be a boolean or number', 'rating.value'); } } } /** * Validates playback state input */ function validatePlaybackState(state: any): asserts state is PlaybackState { if (typeof state !== 'number') { throw new ValidationError('Playback state must be a number', 'state'); } if (!Object.values(PlaybackState).includes(state)) { throw new ValidationError('Invalid playback state value', 'state'); } } /** * Validates position input */ function validatePosition(position: any): asserts position is number { if (typeof position !== 'number') { throw new ValidationError('Position must be a number', 'position'); } if (position < 0) { throw new ValidationError('Position must be non-negative', 'position'); } if (!isFinite(position)) { throw new ValidationError('Position must be finite', 'position'); } } /** * Validates media control options */ function validateMediaControlOptions(options: any): asserts options is MediaControlOptions { if (!options || typeof options !== 'object') { throw new ValidationError('Options must be an object', 'options'); } if (options.capabilities !== undefined) { if (!Array.isArray(options.capabilities)) { throw new ValidationError('capabilities must be an array', 'capabilities'); } for (const capability of options.capabilities) { if (!Object.values(Command).includes(capability)) { throw new ValidationError(`Invalid capability: ${capability}`, 'capabilities'); } } } if (options.notification !== undefined) { if (typeof options.notification !== 'object') { throw new ValidationError('notification must be an object', 'notification'); } } if (options.ios !== undefined) { if (typeof options.ios !== 'object') { throw new ValidationError('ios must be an object', 'ios'); } } if (options.android !== undefined) { if (typeof options.android !== 'object') { throw new ValidationError('android must be an object', 'android'); } } } // ============================================= // TYPE DEFINITIONS // ============================================= /** * Represents the current state of media playback */ export enum PlaybackState { NONE = 0, STOPPED = 1, PLAYING = 2, PAUSED = 3, BUFFERING = 4, ERROR = 5, } /** * Represents different types of media control commands */ export enum Command { PLAY = 'play', PAUSE = 'pause', STOP = 'stop', NEXT_TRACK = 'nextTrack', PREVIOUS_TRACK = 'previousTrack', SKIP_FORWARD = 'skipForward', SKIP_BACKWARD = 'skipBackward', SEEK = 'seek', SET_RATING = 'setRating', VOLUME_UP = 'volumeUp', VOLUME_DOWN = 'volumeDown', } /** * Rating types for media content */ export enum RatingType { HEART = 'heart', THUMBS_UP_DOWN = 'thumbsUpDown', THREE_STARS = 'threeStars', FOUR_STARS = 'fourStars', FIVE_STARS = 'fiveStars', PERCENTAGE = 'percentage', } /** * Represents artwork/album cover information */ export interface MediaArtwork { uri: string; width?: number; height?: number; } /** * Represents rating information for media content */ export interface MediaRating { type: RatingType; value: boolean | number; maxValue?: number; } /** * Complete media metadata information */ export interface MediaMetadata { title?: string; artist?: string; album?: string; artwork?: MediaArtwork; duration?: number; elapsedTime?: number; genre?: string; trackNumber?: number; albumTrackCount?: number; date?: string; rating?: MediaRating; color?: string; colorized?: boolean; } /** * Configuration options for media controls */ export interface MediaControlOptions { capabilities?: Command[]; notification?: { icon?: string; largeIcon?: MediaArtwork; color?: string; showWhenClosed?: boolean; skipInterval?: number; }; ios?: { skipInterval?: number; }; android?: { requestAudioFocus?: boolean; }; } /** * Media control event data */ export interface MediaControlEvent { command: Command; data?: any; timestamp: number; } /** * Audio interruption information */ export interface AudioInterruption { type: 'begin' | 'end'; category?: string; shouldResume?: boolean; } /** * Volume change information */ export interface VolumeChange { volume: number; userInitiated: boolean; } // Event listener types export type MediaControlEventListener = (event: MediaControlEvent) => void; export type AudioInterruptionListener = (interruption: AudioInterruption) => void; export type VolumeChangeListener = (change: VolumeChange) => void; // ============================================= // NATIVE MODULE INTERFACE // ============================================= /** * Native module interface for Expo Media Control * This interface defines all the methods that the native iOS and Android modules must implement */ declare class ExpoMediaControlNativeModule extends NativeModule { /** * Enable media controls with specified configuration * Initializes the media session and sets up remote control handlers */ enableMediaControls(options?: MediaControlOptions): Promise<void>; /** * Disable media controls and clean up all resources * Stops the media session and removes all handlers */ disableMediaControls(): Promise<void>; /** * Update the media metadata displayed in system controls * Updates notification, lock screen, and control center information */ updateMetadata(metadata: MediaMetadata): Promise<void>; /** * Update the current playback state and position * Updates the system about current playback status */ updatePlaybackState(state: PlaybackState, position?: number): Promise<void>; /** * Reset all media control information to default state * Clears all metadata and resets playback state */ resetControls(): Promise<void>; /** * Check if media controls are currently enabled * Returns whether the media session is active */ isEnabled(): Promise<boolean>; /** * Get the current media metadata * Returns the currently set metadata information */ getCurrentMetadata(): Promise<MediaMetadata | null>; /** * Get the current playback state * Returns the current playback status */ getCurrentState(): Promise<PlaybackState>; } // ============================================= // MODULE IMPLEMENTATION // ============================================= // Create the native module instance const nativeModule = requireNativeModule<ExpoMediaControlNativeModule>('ExpoMediaControl'); console.log('📱 JS: Native module loaded:', nativeModule); /** * Map to store event listeners for manual management */ const eventListeners: { mediaControl: MediaControlEventListener[]; audioInterruption: AudioInterruptionListener[]; volumeChange: VolumeChangeListener[]; } = { mediaControl: [], audioInterruption: [], volumeChange: [], }; /** * Extended module class that combines native methods with simplified event handling * This provides a complete interface for media control functionality */ class ExtendedExpoMediaControlModule { // ============================================= // NATIVE METHOD PROXIES // Forward calls to the native module with proper error handling // ============================================= /** * Enable media controls with specified configuration * Initializes the media session and sets up remote control handlers */ enableMediaControls = async (options?: MediaControlOptions): Promise<void> => { try { // Validate input if (options !== undefined) { validateMediaControlOptions(options); } await nativeModule.enableMediaControls(options); // Add native event listeners (nativeModule as any).addListener('mediaControlEvent', this._dispatchMediaControlEvent); (nativeModule as any).addListener('audioInterruptionEvent', this._dispatchAudioInterruptionEvent); (nativeModule as any).addListener('volumeChangeEvent', this._dispatchVolumeChangeEvent); } catch (error) { if (error instanceof ValidationError) { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); const nativeError = new NativeError( `Failed to enable media controls: ${errorMessage}`, 'ENABLE_FAILED', error instanceof Error ? error : undefined ); console.error(nativeError.message); throw nativeError; } }; /** * Disable media controls and clean up all resources * Stops the media session and removes all handlers */ disableMediaControls = async (): Promise<void> => { try { await nativeModule.disableMediaControls(); // Remove all native event listeners (nativeModule as any).removeAllListeners('mediaControlEvent'); (nativeModule as any).removeAllListeners('audioInterruptionEvent'); (nativeModule as any).removeAllListeners('volumeChangeEvent'); } catch (error) { console.error('Failed to disable media controls:', error); throw error; } }; /** * Update the media metadata displayed in system controls * Updates notification, lock screen, and control center information */ updateMetadata = async (metadata: MediaMetadata): Promise<void> => { try { // Validate input validateMetadata(metadata); await nativeModule.updateMetadata(metadata); } catch (error) { if (error instanceof ValidationError) { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); const nativeError = new NativeError( `Failed to update metadata: ${errorMessage}`, 'UPDATE_METADATA_FAILED', error instanceof Error ? error : undefined ); console.error(nativeError.message); throw nativeError; } }; /** * Update the current playback state and position * Updates the system about current playback status */ updatePlaybackState = async (state: PlaybackState, position?: number): Promise<void> => { try { // Validate input validatePlaybackState(state); if (position !== undefined) { validatePosition(position); } await nativeModule.updatePlaybackState(state, position); } catch (error) { if (error instanceof ValidationError) { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); const nativeError = new NativeError( `Failed to update playback state: ${errorMessage}`, 'UPDATE_STATE_FAILED', error instanceof Error ? error : undefined ); console.error(nativeError.message); throw nativeError; } }; /** * Reset all media control information to default state * Clears all metadata and resets playback state */ resetControls = async (): Promise<void> => { try { await nativeModule.resetControls(); } catch (error) { console.error('Failed to reset controls:', error); throw error; } }; /** * Check if media controls are currently enabled * Returns whether the media session is active */ isEnabled = async (): Promise<boolean> => { try { return await nativeModule.isEnabled(); } catch (error) { console.error('Failed to check if controls are enabled:', error); return false; } }; /** * Get the current media metadata * Returns the currently set metadata information */ getCurrentMetadata = async (): Promise<MediaMetadata | null> => { try { return await nativeModule.getCurrentMetadata(); } catch (error) { console.error('Failed to get current metadata:', error); return null; } }; /** * Get the current playback state * Returns the current playback status */ getCurrentState = async (): Promise<PlaybackState> => { try { return await nativeModule.getCurrentState(); } catch (error) { console.error('Failed to get current state:', error); return PlaybackState.NONE; } }; // ============================================= // SIMPLIFIED EVENT HANDLING METHODS // Use manual listener management for better control // ============================================= /** * Add listener for media control events (play, pause, next, etc.) * These events are triggered when users interact with system media controls * @param listener Function to call when media control events occur * @returns Function to remove the listener */ addListener = (listener: MediaControlEventListener): (() => void) => { console.log('📱 JS: Adding media control event listener'); eventListeners.mediaControl.push(listener); // Return removal function return () => { const index = eventListeners.mediaControl.indexOf(listener); if (index > -1) { eventListeners.mediaControl.splice(index, 1); } }; }; /** * Add listener for audio interruption events (calls, notifications) * These events help manage audio focus and playback interruptions * @param listener Function to call when audio interruptions occur * @returns Function to remove the listener */ addAudioInterruptionListener = (listener: AudioInterruptionListener): (() => void) => { eventListeners.audioInterruption.push(listener); // Return removal function return () => { const index = eventListeners.audioInterruption.indexOf(listener); if (index > -1) { eventListeners.audioInterruption.splice(index, 1); } }; }; /** * Add listener for volume change events * These events are triggered when system volume changes * @param listener Function to call when volume changes * @returns Function to remove the listener */ addVolumeChangeListener = (listener: VolumeChangeListener): (() => void) => { eventListeners.volumeChange.push(listener); // Return removal function return () => { const index = eventListeners.volumeChange.indexOf(listener); if (index > -1) { eventListeners.volumeChange.splice(index, 1); } }; }; /** * Remove all event listeners for all event types * Cleans up all subscribed event handlers * @returns Promise that resolves when all listeners are removed */ removeAllListeners = async (): Promise<void> => { eventListeners.mediaControl.length = 0; eventListeners.audioInterruption.length = 0; eventListeners.volumeChange.length = 0; }; // ============================================= // INTERNAL EVENT DISPATCH METHODS // These will be called by the native modules // ============================================= /** * Internal method to dispatch media control events * This will be called by the native modules when control events occur */ _dispatchMediaControlEvent = (event: MediaControlEvent): void => { console.log('📱 JS: Dispatching media control event:', event); eventListeners.mediaControl.forEach(listener => { try { listener(event); } catch (error) { console.error('Error in media control event listener:', error); } }); }; /** * Internal method to dispatch audio interruption events * This will be called by the native modules when interruptions occur */ _dispatchAudioInterruptionEvent = (interruption: AudioInterruption): void => { eventListeners.audioInterruption.forEach(listener => { try { listener(interruption); } catch (error) { console.error('Error in audio interruption event listener:', error); } }); }; /** * Internal method to dispatch volume change events * This will be called by the native modules when volume changes */ _dispatchVolumeChangeEvent = (change: VolumeChange): void => { eventListeners.volumeChange.forEach(listener => { try { listener(change); } catch (error) { console.error('Error in volume change event listener:', error); } }); }; } // Export the extended module instance export default new ExtendedExpoMediaControlModule();