stream-chat-react
Version:
React components to create chat conversations or livestream style chat
457 lines (456 loc) • 18.1 kB
JavaScript
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());
}
}