Source: masters/PlayControl.js

import SchedulingQueue from '../core/SchedulingQueue.js';
import TimeEngine from '../core/TimeEngine.js';

const EPSILON = 1e-8;

class LoopControl extends TimeEngine {
  constructor(playControl) {
    super();

    this.__playControl = playControl;
    this.speed = 1;
    this.lower = -Infinity;
    this.upper = Infinity;
  }

  // TimeEngine method (scheduled interface)
  advanceTime(time) {
    const playControl = this.__playControl;
    const speed = this.speed;
    const lower = this.lower;
    const upper = this.upper;

    if (speed > 0)
      time += EPSILON;
    else
      time -= EPSILON;

    if (speed > 0) {
      playControl.syncSpeed(time, lower, speed, true);
      return playControl.__getTimeAtPosition(upper) - EPSILON;
    } else if (speed < 0) {
      playControl.syncSpeed(time, upper, speed, true);
      return playControl.__getTimeAtPosition(lower) + EPSILON;
    }

    return Infinity;
  }

  reschedule(speed) {
    const playControl = this.__playControl;
    const lower = Math.min(playControl.__loopStart, playControl.__loopEnd);
    const upper = Math.max(playControl.__loopStart, playControl.__loopEnd);

    this.speed = speed;
    this.lower = lower;
    this.upper = upper;

    if (lower === upper)
      speed = 0;

    if (speed > 0)
      this.resetTime(playControl.__getTimeAtPosition(upper) - EPSILON);
    else if (speed < 0)
      this.resetTime(playControl.__getTimeAtPosition(lower) + EPSILON);
    else
      this.resetTime(Infinity);
  }

  applyLoopBoundaries(position, speed) {
    const lower = this.lower;
    const upper = this.upper;

    if (speed > 0 && position >= upper)
      return lower + (position - lower) % (upper - lower);
    else if (speed < 0 && position < lower)
      return upper - (upper - position) % (upper - lower);

    return position;
  }
}

// play controlled base class
class PlayControlled {
  constructor(playControl, engine) {
    this.__playControl = playControl;

    engine.master = this;
    this.__engine = engine;
  }

  syncSpeed(time, position, speed, seek, lastSpeed) {
    this.__engine.syncSpeed(time, position, speed, seek);
  }

  get currentTime() {
    return this.__playControl.currentTime;
  }

  get audioTime() {
    return this.__playControl.audioTime;
  }

  get currentPosition() {
    return this.__playControl.currentPosition;
  }

  destroy() {
    this.__playControl = null;

    this.__engine.master = null;
    this.__engine = null;
  }
}

// play control for engines implementing the *speed-controlled* interface
class PlayControlledSpeedControlled extends PlayControlled {
  constructor(playControl, engine) {
    super(playControl, engine);
  }
}

// play control for engines implmenting the *transported* interface
class PlayControlledTransported extends PlayControlled {
  constructor(playControl, engine) {
    super(playControl, engine);

    this.__schedulerHook = new PlayControlledSchedulerHook(playControl, engine);
  }

  syncSpeed(time, position, speed, seek, lastSpeed) {
    if (speed !== lastSpeed || seek) {
      var nextPosition;

      // resync transported engines
      if (seek || speed * lastSpeed < 0) {
        // seek or reverse direction
        nextPosition = this.__engine.syncPosition(time, position, speed);
      } else if (lastSpeed === 0) {
        // start
        nextPosition = this.__engine.syncPosition(time, position, speed);
      } else if (speed === 0) {
        // stop / pause
        this.__engine.syncPosition(time, position, speed);
        nextPosition = Infinity;

        // if (this.__engine.syncSpeed)
        //   this.__engine.syncSpeed(time, position, 0);
      } else if (this.__engine.syncSpeed) {
        // change speed without reversing direction
        this.__engine.syncSpeed(time, position, speed);
      }

      this.__schedulerHook.resetPosition(nextPosition);
    }
  }

  resetEnginePosition(engine, position = undefined) {
    if (position === undefined) {
      var playControl = this.__playControl;
      var time = playControl.__sync();

      position = this.__engine.syncPosition(time, playControl.__position, playControl.__speed);
    }

    this.__schedulerHook.resetPosition(position);
  }

  destroy() {
    this.__schedulerHook.destroy();
    this.__schedulerHook = null;

    super.destroy();
  }
}

// play control for time engines implementing the *scheduled* interface
class PlayControlledScheduled extends PlayControlled {
  constructor(playControl, engine) {
    super(playControl, engine);

    // scheduling queue becomes master of engine
    engine.master = null;
    this.__schedulingQueue = new PlayControlledSchedulingQueue(playControl, engine);
  }

  syncSpeed(time, position, speed, seek, lastSpeed) {
    if (lastSpeed === 0 && speed !== 0) // start or seek
      this.__engine.resetTime();
    else if (lastSpeed !== 0 && speed === 0) // stop
      this.__engine.resetTime(Infinity);
  }

  destroy() {
    this.__schedulingQueue.destroy();
    super.destroy();
  }
}

// translates transported engine advancePosition into global scheduler times
class PlayControlledSchedulerHook extends TimeEngine {
  constructor(playControl, engine) {
    super();

    this.__playControl = playControl;
    this.__engine = engine;

    this.__nextPosition = Infinity;
    playControl.__scheduler.add(this, Infinity);
  }

  advanceTime(time) {
    var playControl = this.__playControl;
    var engine = this.__engine;
    var position = this.__nextPosition;
    var nextPosition = engine.advancePosition(time, position, playControl.__speed);
    var nextTime = playControl.__getTimeAtPosition(nextPosition);

    this.__nextPosition = nextPosition;
    return nextTime;
  }

  get currentTime() {
    return this.__playControl.currentTime;
  }

  get audioTime() {
    return this.__playControl.audioTime;
  }

  get currentPosition() {
    return this.__playControl.currentPosition;
  }

  resetPosition(position = this.__nextPosition) {
    var time = this.__playControl.__getTimeAtPosition(position);
    this.__nextPosition = position;
    this.resetTime(time);
  }

  destroy() {
    this.__playControl.__scheduler.remove(this);
    this.__playControl = null;
    this.__engine = null;
  }
}

// internal scheduling queue that returns the current position (and time) of the play control
class PlayControlledSchedulingQueue extends SchedulingQueue {
  constructor(playControl, engine) {
    super();
    this.__playControl = playControl;
    this.__engine = engine;

    this.add(engine, Infinity);
    playControl.__scheduler.add(this, Infinity);
  }

  get currentTime() {
    return this.__playControl.currentTime;
  }

  get audioTime() {
    return this.__playControl.audioTime;
  }

  get currentPosition() {
    return this.__playControl.currentPosition;
  }

  destroy() {
    this.__playControl.__scheduler.remove(this);
    this.remove(this.__engine);

    this.__playControl = null;
    this.__engine = null;
  }
}

/**
 * Extends Time Engine to provide playback control of a Time Engine instance.
 *
 * [example]{@link https://rawgit.com/wavesjs/waves-masters/master/examples/transport/index.html}
 *
 * @extends TimeEngine
 * @param {Object} scheduler - instance of Scheduler
 * @param {TimeEngine} engine - engine to control
 *
 * @example
 * import masters from 'waves-masters';
 *
 * const getTimeFunction = () => {
 *   const now = process.hrtime();
 *   return now[0] + now[1] * 1e-9;
 * }
 * const scheduler = new masters.Scheduler(getTimeFunction);
 * const playerEngine = new MyTimeEngine();
 * const playControl = new masters.PlayControl(scheduler, playerEngine);
 *
 * playControl.start();
 */
class PlayControl extends TimeEngine {
  constructor(scheduler, engine, options = {}) {
    super();

    this.__scheduler = scheduler;

    this.__playControlled = null;
    this.__loopControl = null;
    this.__loopStart = 0;
    this.__loopEnd = 1;

    // synchronized tie, position, and speed
    this.__time = 0;
    this.__position = 0;
    this.__speed = 0;

    // non-zero "user" speed
    this.__playingSpeed = 1;

    if (engine)
      this.__setEngine(engine);
  }

  __setEngine(engine) {
    if (engine.master)
      throw new Error("object has already been added to a master");

    if (TimeEngine.implementsSpeedControlled(engine))
      this.__playControlled = new PlayControlledSpeedControlled(this, engine);
    else if (TimeEngine.implementsTransported(engine))
      this.__playControlled = new PlayControlledTransported(this, engine);
    else if (TimeEngine.implementsScheduled(engine))
      this.__playControlled = new PlayControlledScheduled(this, engine);
    else
      throw new Error("object cannot be added to play control");
  }

  __resetEngine() {
    this.__playControlled.destroy();
    this.__playControlled = null;
  }

  /**
   * Calculate/extrapolate playing time for given position
   *
   * @param {Number} position position
   * @return {Number} extrapolated time
   * @private
   */
  __getTimeAtPosition(position) {
    return this.__time + (position - this.__position) / this.__speed;
  }

  /**
   * Calculate/extrapolate playing position for given time
   *
   * @param {Number} time time
   * @return {Number} extrapolated position
   * @private
   */
  __getPositionAtTime(time) {
    return this.__position + (time - this.__time) * this.__speed;
  }

  __sync() {
    const now = this.currentTime;
    this.__position += (now - this.__time) * this.__speed;
    this.__time = now;

    return now;
  }

  /**
   * Get current master time.
   *
   * @name currentTime
   * @type {Number}
   * @memberof PlayControl
   * @instance
   * @readonly
   */
  get currentTime() {
    return this.__scheduler.currentTime;
  }

  /**
   * Get current master time.
   *
   * @name audioTime
   * @type {Number}
   * @memberof PlayControl
   * @instance
   * @readonly
   */
  get audioTime() {
    return this.__scheduler.audioTime;
  }

  /**
   * Get current master position.
   * This function will be replaced when the play-control is added to a master.
   *
   * @name currentPosition
   * @type {Number}
   * @memberof PlayControl
   * @instance
   * @readonly
   */
  get currentPosition() {
    return this.__position + (this.__scheduler.currentTime - this.__time) * this.__speed;
  }

  /**
   * Returns if the play control is running.
   *
   * @name running
   * @type {Boolean}
   * @memberof PlayControl
   * @instance
   * @readonly
   */
  get running() {
    return !(this.__speed === 0);
  }

  set(engine = null) {
    const time = this.__sync();
    const speed = this.__speed;

    if (this.__playControlled !== null && this.__playControlled.__engine !== engine) {

      this.syncSpeed(time, this.__position, 0);

      if (this.__playControlled)
        this.__resetEngine();


      if (this.__playControlled === null && engine !== null) {
        this.__setEngine(engine);

        if (speed !== 0)
          this.syncSpeed(time, this.__position, speed);
      }
    }
  }

  /**
   * Sets the play control loop behavior.
   *
   * @type {Boolean}
   * @name loop
   * @memberof PlayControl
   * @instance
   */
  set loop(enable) {
    if (enable && this.__loopStart > -Infinity && this.__loopEnd < Infinity) {
      if (!this.__loopControl) {
        this.__loopControl = new LoopControl(this);
        this.__scheduler.add(this.__loopControl, Infinity);
      }

      if (this.__speed !== 0) {
        const position = this.currentPosition;
        const lower = Math.min(this.__loopStart, this.__loopEnd);
        const upper = Math.max(this.__loopStart, this.__loopEnd);

        if (this.__speed > 0 && position > upper)
          this.seek(upper);
        else if (this.__speed < 0 && position < lower)
          this.seek(lower);
        else
          this.__loopControl.reschedule(this.__speed);
      }
    } else if (this.__loopControl) {
      this.__scheduler.remove(this.__loopControl);
      this.__loopControl = null;
    }
  }

  get loop() {
    return (!!this.__loopControl);
  }

  /**
   * Sets loop start and end time.
   *
   * @param {Number} loopStart - loop start value.
   * @param {Number} loopEnd - loop end value.
   */
  setLoopBoundaries(loopStart, loopEnd) {
    this.__loopStart = loopStart;
    this.__loopEnd = loopEnd;

    this.loop = this.loop;
  }

  /**
   * Sets loop start value
   *
   * @type {Number}
   * @name loopStart
   * @memberof PlayControl
   * @instance
   */
  set loopStart(loopStart) {
    this.setLoopBoundaries(loopStart, this.__loopEnd);
  }

  get loopStart() {
    return this.__loopStart;
  }

  /**
   * Sets loop end value
   *
   * @type {Number}
   * @name loopEnd
   * @memberof PlayControl
   * @instance
   */
  set loopEnd(loopEnd) {
    this.setLoopBoundaries(this.__loopStart, loopEnd);
  }

  get loopEnd() {
    return this.__loopEnd;
  }

  // TimeEngine method (speed-controlled interface)
  syncSpeed(time, position, speed, seek = false) {
    const lastSpeed = this.__speed;

    if (speed !== lastSpeed || seek) {
      if ((seek || lastSpeed === 0) && this.__loopControl)
        position = this.__loopControl.applyLoopBoundaries(position, speed);

      this.__time = time;
      this.__position = position;
      this.__speed = speed;

      if (this.__playControlled)
        this.__playControlled.syncSpeed(time, position, speed, seek, lastSpeed);

      if (this.__loopControl)
        this.__loopControl.reschedule(speed);
    }
  }

  /**
   * Starts playback
   */
  start() {
    const time = this.__sync();
    this.syncSpeed(time, this.__position, this.__playingSpeed);
  }

  /**
   * Pauses playback and stays at the same position.
   */
  pause() {
    const time = this.__sync();
    this.syncSpeed(time, this.__position, 0);
  }

  /**
   * Stops playback and seeks to initial (0) position.
   */
  stop() {
    const time = this.__sync();
    this.syncSpeed(time, 0, 0, true);
  }

  /**
   * If speed if provided, sets the playback speed. The speed value should
   * be non-zero between -16 and -1/16 or between 1/16 and 16.
   *
   * @type {Number}
   * @name speed
   * @memberof PlayControl
   * @instance
   */
  set speed(speed) {
    const time = this.__sync();

    if (speed >= 0) {
      if (speed < 0.01)
        speed = 0.01;
      else if (speed > 100)
        speed = 100;
    } else {
      if (speed < -100)
        speed = -100;
      else if (speed > -0.01)
        speed = -0.01;
    }

    this.__playingSpeed = speed;

    if (!this.master && this.__speed !== 0)
      this.syncSpeed(time, this.__position, speed);
  }

  get speed() {
    return this.__playingSpeed;
  }

  /**
   * Set (jump to) playing position.
   *
   * @param {Number} position target position
   */
  seek(position) {
    const time = this.__sync();
    this.__position = position;
    this.syncSpeed(time, position, this.__speed, true);
  }
}

export default PlayControl;