react-wavesurfer
Version:
React component wrapper for wavesurfer.js
452 lines (395 loc) • 11.8 kB
JavaScript
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import assign from 'deep-assign';
const EVENTS = [
'audioprocess',
'error',
'finish',
'loading',
'mouseup',
'pause',
'play',
'ready',
'scroll',
'seek',
'zoom'
];
/**
* @description Capitalise the first letter of a string
*/
function capitaliseFirstLetter(string) {
return string
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
/**
* @description Throws an error if the prop is defined and not an integer or not positive
*/
function positiveIntegerProptype(props, propName, componentName) {
const n = props[propName];
if (
n !== undefined &&
(typeof n !== 'number' || n !== parseInt(n, 10) || n < 0)
) {
return new Error(`Invalid ${propName} supplied to ${componentName},
expected a positive integer`);
}
return null;
}
const resizeThrottler = fn => () => {
let resizeTimeout;
if (!resizeTimeout) {
resizeTimeout = setTimeout(() => {
resizeTimeout = null;
fn();
}, 66);
}
};
class Wavesurfer extends Component {
constructor(props) {
super(props);
this.state = {
isReady: false
};
if (typeof WaveSurfer === undefined) {
throw new Error('WaveSurfer is undefined!');
}
this._wavesurfer = Object.create(WaveSurfer);
this._loadMediaElt = this._loadMediaElt.bind(this);
this._loadAudio = this._loadAudio.bind(this);
this._seekTo = this._seekTo.bind(this);
if (this.props.responsive) {
this._handleResize = resizeThrottler(() => {
// pause playback for resize operation
if (this.props.playing) {
this._wavesurfer.pause();
}
// resize the waveform
this._wavesurfer.drawBuffer();
// We allow resize before file isloaded, since we can get wave data from outside,
// so there might not be a file loaded when resizing
if (this.state.isReady) {
// restore previous position
this._seekTo(this.props.pos);
}
// restore playback
if (this.props.playing) {
this._wavesurfer.play();
}
});
}
}
componentDidMount() {
const options = assign({}, this.props.options, {
container: this.wavesurferEl
});
// media element loading is only supported by MediaElement backend
if (this.props.mediaElt) {
options.backend = 'MediaElement';
}
this._wavesurfer.init(options);
// file was loaded, wave was drawn
this._wavesurfer.on('ready', () => {
this.setState({
isReady: true,
pos: this.props.pos
});
// set initial position
if (this.props.pos) {
this._seekTo(this.props.pos);
}
// set initial volume
if (this.props.volume) {
this._wavesurfer.setVolume(this.props.volume);
}
// set initial playing state
if (this.props.playing) {
this._wavesurfer.play();
}
// set initial zoom
if (this.props.zoom) {
this._wavesurfer.zoom(this.props.zoom);
}
});
this._wavesurfer.on('audioprocess', pos => {
this.setState({
pos
});
this.props.onPosChange({
wavesurfer: this._wavesurfer,
originalArgs: [pos]
});
});
// `audioprocess` is not fired when seeking, so we have to plug into the
// `seek` event and calculate the equivalent in seconds (seek event
// receives a position float 0-1) – See the README.md for explanation why we
// need this
this._wavesurfer.on('seek', pos => {
if (this.state.isReady) {
const formattedPos = this._posToSec(pos);
this.setState({
formattedPos
});
this.props.onPosChange({
wavesurfer: this._wavesurfer,
originalArgs: [formattedPos]
});
}
});
// hook up events to callback handlers passed in as props
EVENTS.forEach(e => {
const propCallback = this.props[`on${capitaliseFirstLetter(e)}`];
const wavesurfer = this._wavesurfer;
if (propCallback) {
this._wavesurfer.on(e, (...originalArgs) => {
propCallback({
wavesurfer,
originalArgs
});
});
}
});
// if audioFile prop, load file
if (this.props.audioFile) {
this._loadAudio(this.props.audioFile, this.props.audioPeaks);
}
// if mediaElt prop, load media Element
if (this.props.mediaElt) {
this._loadMediaElt(this.props.mediaElt, this.props.audioPeaks);
}
if (this.props.responsive) {
window.addEventListener('resize', this._handleResize, false);
}
}
// update wavesurfer rendering manually
componentWillReceiveProps(nextProps) {
let newSource = false;
let seekToInNewFile;
// update audioFile
if (this.props.audioFile !== nextProps.audioFile) {
this.setState({
isReady: false
});
this._loadAudio(nextProps.audioFile, nextProps.audioPeaks);
newSource = true;
}
// update mediaElt
if (this.props.mediaElt !== nextProps.mediaElt) {
this.setState({
isReady: false
});
this._loadMediaElt(nextProps.mediaElt, nextProps.audioPeaks);
newSource = true;
}
// update peaks
if (this.props.audioPeaks !== nextProps.audioPeaks) {
if (nextProps.mediaElt) {
this._loadMediaElt(nextProps.mediaElt, nextProps.audioPeaks);
} else {
this._loadAudio(nextProps.audioFile, nextProps.audioPeaks);
}
}
// update position
if (
nextProps.pos !== undefined &&
this.state.isReady &&
nextProps.pos !== this.props.pos &&
nextProps.pos !== this.state.pos
) {
if (newSource) {
seekToInNewFile = this._wavesurfer.on('ready', () => {
this._seekTo(nextProps.pos);
seekToInNewFile.un();
});
} else {
this._seekTo(nextProps.pos);
}
}
// update playing state
if (
!newSource &&
(this.props.playing !== nextProps.playing ||
this._wavesurfer.isPlaying() !== nextProps.playing)
) {
if (nextProps.playing) {
this._wavesurfer.play();
} else {
this._wavesurfer.pause();
}
}
// update volume
if (this.props.volume !== nextProps.volume) {
this._wavesurfer.setVolume(nextProps.volume);
}
// update volume
if (this.props.zoom !== nextProps.zoom) {
this._wavesurfer.zoom(nextProps.zoom);
}
// update audioRate
if (this.props.options.audioRate !== nextProps.options.audioRate) {
this._wavesurfer.setPlaybackRate(nextProps.options.audioRate);
}
// turn responsive on
if (
nextProps.responsive &&
this.props.responsive !== nextProps.responsive
) {
window.addEventListener('resize', this._handleResize, false);
}
// turn responsive off
if (
!nextProps.responsive &&
this.props.responsive !== nextProps.responsive
) {
window.removeEventListener('resize', this._handleResize);
}
}
componentWillUnmount() {
// remove listeners
EVENTS.forEach(e => {
this._wavesurfer.un(e);
});
// destroy wavesurfer instance
this._wavesurfer.destroy();
if (this.props.responsive) {
window.removeEventListener('resize', this._handleResize);
}
}
// receives seconds and transforms this to the position as a float 0-1
_secToPos(sec) {
return 1 / this._wavesurfer.getDuration() * sec;
}
// receives position as a float 0-1 and transforms this to seconds
_posToSec(pos) {
return pos * this._wavesurfer.getDuration();
}
// pos is in seconds, the 0-1 proportional position we calculate here …
_seekTo(sec) {
const pos = this._secToPos(sec);
if (this.props.options.autoCenter) {
this._wavesurfer.seekAndCenter(pos);
} else {
this._wavesurfer.seekTo(pos);
}
}
// load a media element selector or HTML element
// if selector, get the HTML element for it
// and pass to _loadAudio
_loadMediaElt(selectorOrElt, audioPeaks) {
if (selectorOrElt instanceof window.HTMLElement) {
this._loadAudio(selectorOrElt, audioPeaks);
} else {
if (!window.document.querySelector(selectorOrElt)) {
throw new Error('Media Element not found!');
}
this._loadAudio(window.document.querySelector(selectorOrElt), audioPeaks);
}
}
// pass audio data to wavesurfer
_loadAudio(audioFileOrElt, audioPeaks) {
if (audioFileOrElt instanceof window.HTMLElement) {
// media element
this._wavesurfer.loadMediaElement(audioFileOrElt, audioPeaks);
} else if (typeof audioFileOrElt === 'string') {
// bog-standard string is handled by load method and ajax call
this._wavesurfer.load(audioFileOrElt, audioPeaks);
} else if (
audioFileOrElt instanceof window.Blob ||
audioFileOrElt instanceof window.File
) {
// blob or file is loaded with loadBlob method
this._wavesurfer.loadBlob(audioFileOrElt, audioPeaks);
} else {
throw new Error(`Wavesurfer._loadAudio expects prop audioFile
to be either HTMLElement, string or file/blob`);
}
}
render() {
const childrenWithProps = this.props.children
? React.Children.map(this.props.children, child =>
React.cloneElement(child, {
wavesurfer: this._wavesurfer,
isReady: this.state.isReady
})
)
: false;
return (
<div>
<div
ref={c => {
this.wavesurferEl = c;
}}
/>
{childrenWithProps}
</div>
);
}
}
Wavesurfer.propTypes = {
playing: PropTypes.bool,
pos: PropTypes.number,
audioFile: (props, propName, componentName) => {
const prop = props[propName];
if (
prop &&
typeof prop !== 'string' &&
!(prop instanceof window.Blob) &&
!(prop instanceof window.File)
) {
return new Error(`Invalid ${propName} supplied to ${componentName}
expected either string or file/blob`);
}
return null;
},
mediaElt: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(window.HTMLElement)
]),
audioPeaks: PropTypes.array,
volume: PropTypes.number,
zoom: PropTypes.number,
responsive: PropTypes.bool,
onPosChange: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]),
options: PropTypes.shape({
audioRate: PropTypes.number,
backend: PropTypes.oneOf(['WebAudio', 'MediaElement']),
barWidth: (props, propName, componentName) => {
const prop = props[propName];
if (prop !== undefined && typeof prop !== 'number') {
return new Error(`Invalid ${propName} supplied to ${componentName}
expected either undefined or number`);
}
return null;
},
cursorColor: PropTypes.string,
cursorWidth: positiveIntegerProptype,
dragSelection: PropTypes.bool,
fillParent: PropTypes.bool,
height: positiveIntegerProptype,
hideScrollbar: PropTypes.bool,
interact: PropTypes.bool,
loopSelection: PropTypes.bool,
mediaControls: PropTypes.bool,
minPxPerSec: positiveIntegerProptype,
normalize: PropTypes.bool,
pixelRatio: PropTypes.number,
progressColor: PropTypes.string,
scrollParent: PropTypes.bool,
skipLength: PropTypes.number,
waveColor: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(window.CanvasGradient)
]),
autoCenter: PropTypes.bool
})
};
Wavesurfer.defaultProps = {
playing: false,
pos: 0,
options: WaveSurfer.defaultParams,
responsive: true,
onPosChange: () => {}
};
export default Wavesurfer;