codevise/pageflow

View on GitHub
entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/Wavesurfer.js

Summary

Maintainability
D
2 days
Test Coverage
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import assign from 'deep-assign';
import Measure from 'react-measure';

import WaveSurfer from 'wavesurfer.js';

const EVENTS = [
  'audioprocess',
  'error',
  'finish',
  'loading',
  'mouseup',
  'pause',
  'play',
  'ready',
  'scroll',
  'seek',
  'zoom'
];

function capitaliseFirstLetter(string) {
  return string.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('');
}

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;
}

class Wavesurfer extends Component {
  constructor(props) {
    super(props);

    this.state = {
      isReady: false
    };

    if (typeof WaveSurfer === undefined) {
      throw new Error('WaveSurfer is undefined!');
    }

    this._loadMediaElt = this._loadMediaElt.bind(this);
    this._loadAudio = this._loadAudio.bind(this);
    this._seekTo = this._seekTo.bind(this);

    this._handleResize = () => {
      if (this.state.isReady) {
        // Force redraw
        this._wavesurfer.zoom(false);
      }
    };
  }

  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 = WaveSurfer.create(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 != null) {
        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) => {
      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);
    }
  }

  // update wavesurfer rendering manually
  componentDidUpdate(prevProps) {
    // update audioFile
    if (this.props.audioFile !== this.props.audioFile) {
      this._loadAudio(this.props.audioFile, this.props.audioPeaks);
    }

    // update mediaElt
    if (prevProps.mediaElt !== this.props.mediaElt) {
      this._loadMediaElt(this.props.mediaElt, this.props.audioPeaks);
    }

    // update peaks
    if (prevProps.audioPeaks !== this.props.audioPeaks) {
      if (this.props.mediaElt) {
        this._loadMediaElt(prevProps.mediaElt, this.props.audioPeaks);
      } else {
        this._loadAudio(this.props.audioFile, this.props.audioPeaks);
      }
    }

    // update position
    if (this.props.pos &&
        this.state.isReady &&
        prevProps.pos !== this.props.pos &&
        this.props.pos !== this.state.pos) {
      this._seekTo(this.props.pos);
    }

    // update playing state
    if (this.props.playing !== prevProps.playing) {
      if (this.props.playing) {
        this._wavesurfer.play();
      } else {
        this._wavesurfer.pause();
      }
    }

    // update volume
    if (this.props.volume !== prevProps.volume) {
      this._wavesurfer.setVolume(this.props.volume);
    }

    // update volume
    if (this.props.zoom !== prevProps.zoom) {
      this._wavesurfer.zoom(this.props.zoom);
    }

    // update audioRate
    if (this.props.options.audioRate !== prevProps.options.audioRate) {
      this._wavesurfer.setPlaybackRate(this.props.options.audioRate);
    }

    if (prevProps.options.waveColor !== this.props.options.waveColor) {
      this._wavesurfer.setWaveColor(this.props.options.waveColor);
    }

    if (prevProps.options.progressColor !== this.props.options.progressColor) {
      this._wavesurfer.setProgressColor(this.props.options.progressColor);
    }

    if (prevProps.options.cursorColor !== this.props.options.cursorColor) {
      this._wavesurfer.setCursorColor(this.props.options.cursorColor);
    }

    if (prevProps.options.height !== this.props.options.height) {
      this._wavesurfer.setHeight(this.props.options.height);
    }
  }

  componentWillUnmount() {
    // remove listeners
    EVENTS.forEach((e) => {
      this._wavesurfer.un(e);
    });

    // destroy wavesurfer instance
    this._wavesurfer.destroy();
  }

  // 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 global.HTMLElement) {
      this._loadAudio(selectorOrElt, audioPeaks);
    } else {
      // Ignore if media element cannot be found. There are edge cases
      // where React already unmounted the corresponding media element,
      // but the parent Waveform component still holds the old media
      // element id.
      if (global.document.querySelector(selectorOrElt)) {
        this._loadAudio(global.document.querySelector(selectorOrElt), audioPeaks);
      }
    }
  }

  // pass audio data to wavesurfer
  _loadAudio(audioFileOrElt, audioPeaks) {
    if (audioFileOrElt instanceof global.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 global.Blob || audioFileOrElt instanceof global.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`);
    }
  }

  _measureIfResponsive(children) {
    if (this.props.responsive) {
      return (
        <Measure client onResize={this._handleResize}>
          {({ measureRef }) => (
            <div ref={measureRef}>
              {children}
            </div>
          )

          }
        </Measure>
      );
    }

    return children;
  }

  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 this._measureIfResponsive(
      <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 global.Blob) &&
        !(prop instanceof global.File)) {
      return new Error(`Invalid ${propName} supplied to ${componentName}
        expected either string or file/blob`);
    }

    return null;
  },

  mediaElt: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.instanceOf(global.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.string,
    autoCenter: PropTypes.bool
  })
};

Wavesurfer.defaultProps = {
  playing: false,
  pos: 0,
  options: WaveSurfer.defaultParams,
  responsive: true,
  onPosChange: () => {}
};

export default Wavesurfer;