import type * as ExpoAudio from 'expo-audio'; import type * as ExpoAV from 'expo-av'; import { Logger, matchesOneOf } from '@sendbird/uikit-utils'; import expoBackwardUtils from '../utils/expoBackwardUtils'; import type { ExpoAudioModule } from '../utils/expoBackwardUtils'; import type { PlayerServiceInterface, Unsubscribe } from './types'; type Modules = { avModule: ExpoAudioModule; }; type PlaybackListener = Parameters[number]; type StateListener = Parameters[number]; interface AudioPlayerAdapter { requestPermission(): Promise; play(uri: string): Promise; pause(): Promise; stop(): Promise; reset(): Promise; seek(time: number): Promise; addPlaybackListener(callback: PlaybackListener): Unsubscribe; addStateListener(callback: StateListener): Unsubscribe; readonly state: PlayerServiceInterface['state']; uri?: string; } abstract class BaseAudioPlayerAdapter implements AudioPlayerAdapter { uri?: string; state: PlayerServiceInterface['state'] = 'idle'; protected readonly playbackSubscribers = new Set(); protected readonly stateSubscribers = new Set(); protected setState = (state: PlayerServiceInterface['state']) => { this.state = state; this.stateSubscribers.forEach((callback) => { callback(state); }); }; public requestPermission = async (): Promise => { return true; }; public addPlaybackListener = (callback: PlaybackListener): Unsubscribe => { this.playbackSubscribers.add(callback); return () => { this.playbackSubscribers.delete(callback); }; }; public addStateListener = (callback: StateListener): Unsubscribe => { this.stateSubscribers.add(callback); return () => { this.stateSubscribers.delete(callback); }; }; abstract play(uri: string): Promise; abstract pause(): Promise; abstract stop(): Promise; abstract reset(): Promise; abstract seek(time: number): Promise; } class LegacyExpoAVPlayerAdapter extends BaseAudioPlayerAdapter { private readonly sound: ExpoAV.Audio.Sound; constructor(avModule: typeof ExpoAV) { super(); this.sound = new avModule.Audio.Sound(); } private setListener = () => { this.sound.setProgressUpdateIntervalAsync(100).catch((error) => { Logger.warn('[PlayerService.Expo] Failed to set progress update interval', error); }); this.sound.setOnPlaybackStatusUpdate((status) => { if (status.isLoaded) { if (status.didJustFinish) { this.stop().catch((error) => { Logger.warn('[PlayerService.Expo] Failed to stop in OnPlaybackStatusUpdate', error); }); } if (status.isPlaying) { this.playbackSubscribers.forEach((callback) => { callback({ currentTime: status.positionMillis, duration: status.durationMillis ?? 0, stopped: status.didJustFinish, }); }); } } }); }; private removeListener = () => { this.sound.setOnPlaybackStatusUpdate(null); }; private prepare = async (uri: string) => { this.setState('preparing'); await this.sound.loadAsync({ uri }, { shouldPlay: false }, true); this.uri = uri; }; public play = async (uri: string): Promise => { if (matchesOneOf(this.state, ['idle', 'stopped'])) { try { await this.prepare(uri); this.setListener(); await this.sound.playAsync(); this.setState('playing'); } catch (e) { this.setState('idle'); this.uri = undefined; this.removeListener(); throw e; } } else if (matchesOneOf(this.state, ['paused']) && this.uri === uri) { try { this.setListener(); await this.sound.playAsync(); this.setState('playing'); } catch (e) { this.removeListener(); throw e; } } }; public pause = async (): Promise => { if (matchesOneOf(this.state, ['playing'])) { await this.sound.pauseAsync(); this.removeListener(); this.setState('paused'); } }; public stop = async (): Promise => { if (matchesOneOf(this.state, ['playing', 'paused'])) { await this.sound.stopAsync(); await this.sound.unloadAsync(); this.removeListener(); this.setState('stopped'); } }; public reset = async (): Promise => { await this.stop(); this.setState('idle'); this.uri = undefined; this.playbackSubscribers.clear(); this.stateSubscribers.clear(); }; public seek = async (time: number): Promise => { if (matchesOneOf(this.state, ['playing', 'paused'])) { await this.sound.playFromPositionAsync(time); } }; } class ExpoAudioPlayerAdapter extends BaseAudioPlayerAdapter { private readonly audioModule: typeof ExpoAudio; private player: ExpoAudio.AudioPlayer | null = null; constructor(audioModule: typeof ExpoAudio) { super(); this.audioModule = audioModule; } private setListener = () => { if (!this.player) return; this.player.addListener('playbackStatusUpdate', (status) => { if (status.isLoaded) { if (status.didJustFinish) { this.stop().catch((error) => { Logger.warn('[PlayerService.Expo] Failed to stop in playbackStatusUpdate', error); }); } if (status.playing) { this.playbackSubscribers.forEach((callback) => { callback({ currentTime: status.currentTime, duration: status.duration ?? 0, stopped: status.didJustFinish, }); }); } } }); }; private removeListener = () => { if (this.player) { this.player.remove(); } }; private prepare = async (uri: string) => { this.setState('preparing'); this.player = this.audioModule.createAudioPlayer(uri, { updateInterval: 100 }); this.uri = uri; }; public play = async (uri: string): Promise => { if (matchesOneOf(this.state, ['idle', 'stopped'])) { try { await this.prepare(uri); this.setListener(); this.player?.play(); this.setState('playing'); } catch (e) { this.setState('idle'); this.uri = undefined; this.removeListener(); throw e; } } else if (matchesOneOf(this.state, ['paused']) && this.uri === uri) { try { this.setListener(); this.player?.play(); this.setState('playing'); } catch (e) { this.removeListener(); throw e; } } }; public pause = async (): Promise => { if (matchesOneOf(this.state, ['playing'])) { this.player?.pause(); this.removeListener(); this.setState('paused'); } }; public stop = async (): Promise => { if (matchesOneOf(this.state, ['playing', 'paused'])) { this.player?.pause(); this.removeListener(); this.setState('stopped'); } }; public reset = async (): Promise => { await this.stop(); this.player?.remove(); this.player = null; this.setState('idle'); this.uri = undefined; this.playbackSubscribers.clear(); this.stateSubscribers.clear(); }; public seek = async (time: number): Promise => { if (matchesOneOf(this.state, ['playing', 'paused']) && this.player) { this.player.currentTime = time; } }; } const createExpoPlayerService = ({ avModule }: Modules): PlayerServiceInterface => { if (expoBackwardUtils.expoAV.isLegacyAVModule(avModule)) { Logger.warn( '[PlayerService.Expo] expo-av is deprecated and will be removed in Expo 54. Please migrate to expo-audio.', ); } return expoBackwardUtils.expoAV.isAudioModule(avModule) ? new ExpoAudioPlayerAdapter(avModule) : new LegacyExpoAVPlayerAdapter(avModule); }; export default createExpoPlayerService;