UNPKG

stream-chat-react

Version:

React components to create chat conversations or livestream style chat

457 lines (456 loc) 18.1 kB
import { StateStore } from 'stream-chat'; import throttle from 'lodash.throttle'; const DEFAULT_PLAYBACK_RATES = [1.0, 1.5, 2.0]; const isSeekable = (audioElement) => !(audioElement.duration === Infinity || isNaN(audioElement.duration)); export const defaultRegisterAudioPlayerError = ({ error, } = {}) => { if (!error) return; console.error('[AUDIO PLAYER]', error); }; export const elementIsPlaying = (audioElement) => audioElement && !(audioElement.paused || audioElement.ended); export class AudioPlayer { constructor({ durationSeconds, fileSize, id, mimeType, playbackRates: customPlaybackRates, plugins, pool, src, title, waveformData, }) { this._plugins = new Map(); this.playTimeout = undefined; this.unsubscribeEventListeners = null; this._disposed = false; this._restoringPosition = false; this._removalTimeout = undefined; this.setPlaybackStartSafetyTimeout = () => { clearTimeout(this.playTimeout); this.playTimeout = setTimeout(() => { if (!this.elementRef) return; try { this.elementRef.pause(); this.state.partialNext({ isPlaying: false }); } catch (e) { this.registerError({ errCode: 'failed-to-start' }); } }, 2000); }; this.clearPlaybackStartSafetyTimeout = () => { if (!this.elementRef) return; clearTimeout(this.playTimeout); this.playTimeout = undefined; }; this.clearPendingLoadedMeta = () => { const pending = this._pendingLoadedMeta; if (pending?.element && pending.onLoaded) { pending.element.removeEventListener('loadedmetadata', pending.onLoaded); } this._pendingLoadedMeta = undefined; }; this.restoreSavedPosition = (elementRef) => { const saved = this.secondsElapsed; if (!saved || saved <= 0) return; const apply = () => { const duration = elementRef.duration; const clamped = typeof duration === 'number' && !isNaN(duration) && isFinite(duration) ? Math.min(saved, duration) : saved; try { if (elementRef.currentTime === clamped) return; elementRef.currentTime = clamped; // Preempt UI with restored position to avoid flicker this.setSecondsElapsed(clamped); } catch { // ignore } }; // No information is available about the media resource. if (elementRef.readyState < 1) { this.clearPendingLoadedMeta(); this._restoringPosition = true; const onLoaded = () => { // Ensure this callback still belongs to the same pending registration and same element if (this._pendingLoadedMeta?.onLoaded !== onLoaded) return; this._pendingLoadedMeta = undefined; if (this.elementRef !== elementRef) { this._restoringPosition = false; return; } apply(); this._restoringPosition = false; }; elementRef.addEventListener('loadedmetadata', onLoaded, { once: true }); this._pendingLoadedMeta = { element: elementRef, onLoaded }; } else { this._restoringPosition = true; apply(); this._restoringPosition = false; } }; this.elementIsReady = () => { if (this._elementIsReadyPromise) return this._elementIsReadyPromise; this._elementIsReadyPromise = new Promise((resolve) => { if (!this.elementRef) return resolve(false); const element = this.elementRef; const handleLoaded = () => { element.removeEventListener('loadedmetadata', handleLoaded); resolve(element.readyState > 0); }; element.addEventListener('loadedmetadata', handleLoaded); }); return this._elementIsReadyPromise; }; this.setRef = (elementRef) => { if (elementIsPlaying(this.elementRef)) { // preserve state during swap this.releaseElement({ resetState: false }); } this.clearPendingLoadedMeta(); this._restoringPosition = false; this._elementIsReadyPromise = undefined; this.state.partialNext({ elementRef }); // When a new element is attached, make sure listeners are wired to it if (elementRef) { this.registerSubscriptions(); } }; this.setSecondsElapsed = (secondsElapsed) => { this.state.partialNext({ progressPercent: this.elementRef && secondsElapsed ? (secondsElapsed / this.elementRef.duration) * 100 : 0, secondsElapsed, }); }; this.canPlayMimeType = (mimeType) => { if (!mimeType) return false; if (this.elementRef) return !!this.elementRef.canPlayType(mimeType); return !!new Audio().canPlayType(mimeType); }; this.play = async (params) => { if (this._disposed) return; const elementRef = this.ensureElementRef(); if (elementIsPlaying(this.elementRef)) { if (this.isPlaying) return; this.state.partialNext({ isPlaying: true }); return; } const { currentPlaybackRate, playbackRates } = { currentPlaybackRate: this.currentPlaybackRate, playbackRates: this.playbackRates, ...params, }; if (!this.canPlayRecord) { this.registerError({ errCode: 'not-playable' }); return; } // Restore last known position for this player before attempting to play this.restoreSavedPosition(elementRef); elementRef.playbackRate = currentPlaybackRate ?? this.currentPlaybackRate; this.setPlaybackStartSafetyTimeout(); try { await elementRef.play(); this.state.partialNext({ currentPlaybackRate, isPlaying: true, playbackRates, }); this._pool.setActiveAudioPlayer(this); } catch (e) { this.registerError({ error: e }); this.state.partialNext({ isPlaying: false }); } finally { this.clearPlaybackStartSafetyTimeout(); } }; this.pause = () => { if (!elementIsPlaying(this.elementRef)) return; this.clearPlaybackStartSafetyTimeout(); // existence of the element already checked by elementIsPlaying // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.elementRef.pause(); this.state.partialNext({ isPlaying: false }); }; this.stop = () => { this.pause(); this.setSecondsElapsed(0); if (this.elementRef) this.elementRef.currentTime = 0; }; this.togglePlay = async () => (this.isPlaying ? this.pause() : await this.play()); this.increasePlaybackRate = () => { if (!this.elementRef) return; let currentPlaybackRateIndex = this.state .getLatestValue() .playbackRates.findIndex((rate) => rate === this.currentPlaybackRate); if (currentPlaybackRateIndex === -1) { currentPlaybackRateIndex = 0; } const nextIndex = currentPlaybackRateIndex === this.playbackRates.length - 1 ? 0 : currentPlaybackRateIndex + 1; const currentPlaybackRate = this.playbackRates[nextIndex]; this.state.partialNext({ currentPlaybackRate }); this.elementRef.playbackRate = currentPlaybackRate; }; this.seek = throttle(async ({ clientX, currentTarget }) => { let element = this.elementRef; if (!this.elementRef) { element = this.ensureElementRef(); const isReady = await this.elementIsReady(); if (!isReady) return; } if (!currentTarget || !element) return; if (!isSeekable(element)) { this.registerError({ errCode: 'seek-not-supported' }); return; } const { width, x } = currentTarget.getBoundingClientRect(); const ratio = (clientX - x) / width; if (ratio > 1 || ratio < 0) return; const currentTime = ratio * element.duration; this.setSecondsElapsed(currentTime); element.currentTime = currentTime; }, 16); this.registerError = (params) => { defaultRegisterAudioPlayerError(params); this.plugins.forEach(({ onError }) => onError?.({ player: this, ...params })); }; /** * Removes the audio element reference, event listeners and audio player from the player pool. * Helpful when only a single AudioPlayer instance is to be removed from the AudioPlayerPool. */ this.requestRemoval = () => { this._disposed = true; this.cancelScheduledRemoval(); this.clearPendingLoadedMeta(); this._restoringPosition = false; this.releaseElement({ resetState: true }); this.unsubscribeEventListeners?.(); this.unsubscribeEventListeners = null; this.plugins.forEach(({ onRemove }) => onRemove?.({ player: this })); this._pool.deregister(this.id); }; this.cancelScheduledRemoval = () => { clearTimeout(this._removalTimeout); this._removalTimeout = undefined; }; this.scheduleRemoval = (ms = 0) => { this.cancelScheduledRemoval(); this._removalTimeout = setTimeout(() => { if (this.disposed) return; this.requestRemoval(); }, ms); }; /** * Releases only the underlying element back to the pool without disposing the player instance. * Used by the pool to hand off the shared element in single-playback mode. */ this.releaseElementForHandoff = () => { if (!this.elementRef) return; this.releaseElement({ resetState: false }); this.unsubscribeEventListeners?.(); this.unsubscribeEventListeners = null; }; this.registerSubscriptions = () => { this.unsubscribeEventListeners?.(); const audioElement = this.elementRef; if (!audioElement) return; const handleEnded = () => { this.state.partialNext({ isPlaying: false, secondsElapsed: audioElement?.duration ?? this.durationSeconds ?? 0, }); }; const handleError = (e) => { // if fired probably is one of these (e.srcElement.error.code) // 1 = MEDIA_ERR_ABORTED (fetch aborted by user/JS) // 2 = MEDIA_ERR_NETWORK (network failed while fetching) // 3 = MEDIA_ERR_DECODE (data fetched but couldn’t decode) // 4 = MEDIA_ERR_SRC_NOT_SUPPORTED (no resource supported / bad type) // reported during the mount so only logging to the console const audio = e.currentTarget; const state = { isPlaying: false }; if (!audio?.error?.code) { this.state.partialNext(state); return; } if (audio.error.code === 4) { state.canPlayRecord = false; this.state.partialNext(state); } const errorMsg = [ undefined, 'MEDIA_ERR_ABORTED: fetch aborted by user', 'MEDIA_ERR_NETWORK: network failed while fetching', 'MEDIA_ERR_DECODE: audio fetched but couldn’t decode', 'MEDIA_ERR_SRC_NOT_SUPPORTED: source not supported', ][audio?.error?.code]; if (!errorMsg) return; defaultRegisterAudioPlayerError({ error: new Error(errorMsg + ` (${audio.src})`) }); }; const handleTimeupdate = () => { const t = audioElement?.currentTime ?? 0; // Ignore spurious zero during restore/handoff to avoid UI flicker if (this._restoringPosition && t === 0) return; // Also avoid regressing UI to zero if we already have non-zero progress and we're not playing if (!this.isPlaying && t === 0 && this.secondsElapsed > 0) return; this.setSecondsElapsed(t); }; audioElement.addEventListener('ended', handleEnded); audioElement.addEventListener('error', handleError); audioElement.addEventListener('timeupdate', handleTimeupdate); this.unsubscribeEventListeners = () => { audioElement.pause(); audioElement.removeEventListener('ended', handleEnded); audioElement.removeEventListener('error', handleError); audioElement.removeEventListener('timeupdate', handleTimeupdate); }; }; this._data = { durationSeconds, fileSize, id, mimeType, src, title, waveformData, }; this._pool = pool; this.setPlugins(() => plugins ?? []); const playbackRates = customPlaybackRates?.length ? customPlaybackRates : DEFAULT_PLAYBACK_RATES; // do not create element here; only evaluate canPlayRecord cheaply const canPlayRecord = mimeType ? !!new Audio().canPlayType(mimeType) : true; this.state = new StateStore({ canPlayRecord, currentPlaybackRate: playbackRates[0], elementRef: null, isPlaying: false, playbackError: null, playbackRates, progressPercent: 0, secondsElapsed: 0, }); this.plugins.forEach((p) => p.onInit?.({ player: this })); } get plugins() { return Array.from(this._plugins.values()); } get canPlayRecord() { return this.state.getLatestValue().canPlayRecord; } get elementRef() { return this.state.getLatestValue().elementRef; } get isPlaying() { return this.state.getLatestValue().isPlaying; } get currentPlaybackRate() { return this.state.getLatestValue().currentPlaybackRate; } get playbackRates() { return this.state.getLatestValue().playbackRates; } get durationSeconds() { return this._data.durationSeconds; } get fileSize() { return this._data.fileSize; } get id() { return this._data.id; } get src() { return this._data.src; } get mimeType() { return this._data.mimeType; } get title() { return this._data.title; } get waveformData() { return this._data.waveformData; } get secondsElapsed() { return this.state.getLatestValue().secondsElapsed; } get progressPercent() { return this.state.getLatestValue().progressPercent; } get disposed() { return this._disposed; } ensureElementRef() { if (this._disposed) { throw new Error('AudioPlayer is disposed'); } if (!this.elementRef) { const el = this._pool.acquireElement({ ownerId: this.id, src: this.src, }); this.setRef(el); } return this.elementRef; } setDescriptor(descriptor) { this._data = { ...this._data, ...descriptor }; if (descriptor.src !== this.src && this.elementRef) { this.elementRef.src = descriptor.src; } } releaseElement({ resetState }) { this.clearPendingLoadedMeta(); this._restoringPosition = false; if (resetState) { this.stop(); } else { // Ensure isPlaying reflects reality, but keep progress/seconds this.state.partialNext({ isPlaying: false }); if (this.elementRef) { try { this.elementRef.pause(); } catch { // ignore } } } if (this.elementRef) { this._pool.releaseElement(this.id); this.setRef(null); } } setPlugins(setter) { this._plugins = setter(this.plugins).reduce((acc, plugin) => { if (plugin.id) { acc.set(plugin.id, plugin); } return acc; }, new Map()); } }