UNPKG

@firstcoders/hls-web-audio

Version:
516 lines (422 loc) 13.9 kB
import Observer from './observer.js'; import AudioContext from './lib/audio-context.js'; import { fadeIn, fadeOut } from './lib/fade.js'; import isIOS from './lib/isIOS'; import unmuteAudioContext from './lib/unmuteAudioContext.js'; import Timeframe from './timeframe.js'; /** * A controller is used to control the playback of one or more HLS tracks * @class Controller */ class Controller extends Observer { /** * @property {Integer|undefined} adjustedStart - A number inidicating where, relative to the audioContext.currentTime the hypothetical time t=0 would be. Depending on seeking, this can be a negative number. * @example * t = 10 9 8 7 6 5 4 3 2 1 0 1 2 3 4 5 6 7 8 9 10 * ---------------------- // a 10 second track, seeked to 0s at t = 5 => adjustedStart = 5 * ---------------------- // a 10 second track, seeked to 0s at t = 0 => adjustedStart = 0 * --------------------- // a 10 second track, seeked to 5s at t = 0 => adjustedStart = -5 * --------------------- // a 10 second track, seeked to 9s at t = 0 => adjustedStart = -9 */ adjustedStart; /** * @property {Array} hls - The HLS tracks being controlled by this controller * @private */ hls = []; #offset = 0; /** * @constructor * @param {Object} param0 [{}] - A parameter object * @param {AudioContext} [AudioContext] - An instance of an audiocontext * @param {Object} acOpts - An object representing options for auto-instantiating an audiocontext * @param {String} refreshRate [250] - How often a "timeupdate" event is triggered * @param {Object} destination [audioContext.destination] - The destination audio node on which all audionodes send data * @param {Integer} duration - The duration in seconds * @param {Boolean} unmuteAc - Unmute the AC so the we can playback with the IOS mute switch on */ constructor({ ac, acOpts, refreshRate, destination, duration, loop, unmuteAc = true } = {}) { super(); // use or create a new audioContext this.ac = ac || new AudioContext(acOpts); // unmute the AC if (unmuteAc && isIOS()) unmuteAudioContext(this.ac); // if we create a new audiocontext here, we will want to destroy it later to free up memory // see https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/close this.closeAcOnDestroy = !ac; // how often a "timeupdate" event is triggered // this also determines how often the controller checks if the current|next segments need loading // and if the playback needs to be paused due to buffering this.refreshRate = refreshRate || 250; // The destination audio node on which all audionodes send data this.destination = destination || this.ac.destination; // create a gainnode for the master volume this.gainNode = this.ac.createGain(); // connect it to the destination this.gainNode.connect(this.destination); // store the handler so that we can remove it this.onStateChange = () => { if (this.ac.state === 'running') this.tick(); else this.untick(); }; this.ac.addEventListener('statechange', this.onStateChange); // make sure we stop the clock this.ac.suspend(); // by default we are suspended this.desiredState = 'suspended'; // set the duration, if supplied if (duration) this.playDuration = duration; this.loop = loop; } /** * Destructor for cleanup */ destroy() { // stop time this.untick(); // remove references this.hls = []; // disconect volume this.gainNode.disconnect(); this.gainNode = null; // unset this.ac.removeEventListener('statechange', this.onStateChange); // close the audiocontext, if it was created by the controller if (this.closeAcOnDestroy) this.ac.close(); this.ac = null; // remove all event listeners this.unAll(); } /** * Register a HLS instance which is to be controlled by this controller * @param {HLS} hls - An instance of a HLS track */ observe(hls) { if (this.hls.indexOf(hls) === -1) this.hls.push(hls); } /** * Unregister a HLS instance which is no longer to be controlled by this controller * @param {HLS} hls - An instance of a HLS track */ unobserve(hls) { this.hls.splice(this.hls.indexOf(hls), 1); this.notifyUpdated('duration', this.duration); } /** * Start playback * * TODO inconsistent return * * @throws {Error} - Will throw an error if no HLS tracks that are observed by this controller have been loaded and duration cannot be determined. */ async play() { this.desiredState = 'resumed'; if (typeof this.duration !== 'number') throw new Error('Cannot play before loading content'); if (this.isBuffering) throw new Error('The player is buffering'); // seek to 0 when starting playback for the first time if (this.ac.state === 'suspended') { await this.ac.resume(); } if (typeof this.adjustedStart !== 'number') this.fixAdjustedStart(this.offset); this.fireEvent('start'); } /** * Stop playback */ async pause() { this.desiredState = 'suspended'; if (this.ac.state !== 'suspended') await this.ac.suspend(); this.fireEvent('pause'); } /** * Executes the tick callback * * @private * @fires Object#timeupdate */ // eslint-disable-next-line consistent-return tick() { // Prevent multiple ticks running concurrently if (this.tTick) this.untick(); // Detect if we're reached the end if (this.currentTime > this.offset + this.playDuration) { return this.end(); } this.fireEvent('timeupdate', { t: this.currentTime, pct: this.pct, remaining: this.remaining, act: this.ac.currentTime, }); // schedule next tick if (this.ac.state === 'running') this.tTick = setTimeout(() => this.tick(), this.refreshRate); } /** * Initiate buffering * @private */ bufferingStart() { this.fireEvent('pause-start'); this.isBuffering = true; if (this.ac.state === 'running') this.ac.suspend(); } /** * Terminate buffering * @private */ bufferingEnd() { if (this.desiredState === 'resumed') this.ac.resume(); this.isBuffering = false; this.fireEvent('pause-end'); } /** * A means to let HLSs to notify the controller of an event * @param {String} payload - the type of event that occurred in the HLS object */ notify(event, payload) { if (event === 'loading-start' && !this.canPlay && !this.isBuffering) this.bufferingStart(); if (event === 'loading-end' && this.canPlay && this.isBuffering) this.bufferingEnd(); if (event === 'error') { this.fireEvent('error', payload); // eslint-disable-next-line no-console console.error(payload); } if (event === 'init') { this.fireEvent('init', payload); // if a stem has initialised, the duration could have changed this.notifyUpdated('duration', this.duration); } // if a hls.start time has changed (manually set), the duration could have changed if (event === 'start') this.notifyUpdated('duration', this.duration); // if a stem duration is manually set, the duration could have changed if (event === 'duration') this.notifyUpdated('duration', this.duration); } /** * Stops the tick callback * * @private */ untick() { if (this.tTick) clearTimeout(this.tTick); this.tTick = null; } /** * Duration is max duration of all tracks being controlled by this controller * * @returns {Int} - The max of the duration of the hls tracks that are controlled by this controller */ get audioDuration() { // if the duration was manually set, return that // if (this._duration) return this._duration; const max = Math.max.apply( null, this.hls.map((hls) => hls.end).filter((duration) => !!duration), ); // store the previously calculated value this._previousDuration = max; // when there are no durations, -Infinity can come out of the above calc return max > 0 ? max : undefined; } /** * Gives the option to override the duration * @param {Integer} duration - The duration in seconds */ set playDuration(duration) { if (duration && typeof duration !== 'number') throw new TypeError('The property "playDuration" must be of type number'); this.durationOverride = duration; this.notifyUpdated('playDuration', this.playDuration); } /** * Return the playback duration */ get playDuration() { return this.durationOverride || this.audioDuration; } /** * Alias for audioDuration (as in, total duration - not playback duration) */ get duration() { return this.audioDuration; } /** * @deprecated use set playDuration */ set duration(duration) { this.playDuration = duration; } /** * @param {Integer} duration - The offset in seconds */ set offset(offset = 0) { if (typeof offset !== 'number') throw new TypeError('The property "offset" must be of type number'); this.#offset = offset; this.notifyUpdated('offset', this.offset); } get offset() { return this.#offset; } /** * @returns {Integer|undefined} - The current time, in seconds. */ get currentTime() { if (this.rawCurrentTime < this.offset) { this.fixAdjustedStart(this.offset); } if (this.loop && this.rawCurrentTime >= this.offset + this.playDuration) { this.fixAdjustedStart(this.offset); } return this.rawCurrentTime; } /** * Set the current time * * @param {Integer} t - The current time, in seconds * @fires Object#seek */ set currentTime(t) { this.#setCurrentTime(t); } #setCurrentTime(t) { if (typeof this.duration !== 'number' || t < 0 || t > this.duration) throw new Error(`CurrentTime ${t} should be between 0 and duration ${this.duration}`); let seekTo = t; // ensure we're seeking in the available range if (seekTo < this.offset || seekTo > this.offset + this.playDuration) { seekTo = this.offset; } this.fixAdjustedStart(seekTo); // seek: suspend the ac before emitting the seek event: disconnecting audio nodes on a runnin ac can cause "cracks" and "pops". this.ac.suspend().then(() => { if (this.desiredState === 'resumed' && !this.isBuffering) this.ac.resume(); }); } get currentTimeframe() { return new Timeframe({ adjustedStart: this.adjustedStart, adjustedEnd: this.adjustedEnd, currentTime: this.currentTime, playDuration: this.playDuration, offset: this.offset, }); } /** * @returns {Integer|undefined} - The current time, in seconds, without taking loop into consideration */ get rawCurrentTime() { return this.adjustedStart !== undefined ? this.ac.currentTime - this.adjustedStart : undefined; } /** * Calculates the adjusted start time (determining where the "0" point lies) relative to the ac time * @param {Float} t */ fixAdjustedStart(t) { this.adjustedStart = this.ac.currentTime - t; this.fireEvent('seek', { t: this.currentTime, pct: this.pct, remaining: this.remaining, }); } /** * Sets the current time * @param {Integer} n - The current time between 0 and 1 */ set pct(n) { if (this.duration) { let factor = n; if (factor < 0) factor = 0; if (factor > 1) factor = 1; this.currentTime = factor * this.duration; } } /** * Gets the current time, in percent * @returns The current time, in percent */ get pct() { return this.currentTime / this.duration; } /** * Get the playback state * @returns {String} The audiocontext state */ get state() { return this.ac.state; } /** * Get the master volume * @returns {Integer} The master volume */ get volume() { return this.gainNode.gain.value; } /** * Set the master volume * @param {Integer} v - The master volume */ set volume(v) { this.gainNode.gain.value = v; } /** * Resets the controller * @private */ async reset() { await this.pause(); this.adjustedStart = undefined; this.desiredState = 'suspended'; } /** * Whether the controller can play the current segments of all the hls tracks under it's control */ get canPlay() { return !this.hls.find((hls) => !hls.shouldAndCanPlay); } get isSeeking() { return !!this.hls.find((hls) => hls.isSeeking); } /** * @private */ end() { this.reset(); this.fireEvent('end'); } /** * Get the remaining time (in seconds) */ get remaining() { return this.duration - this.currentTime; } fadeIn(duration = 1) { fadeIn(this.gainNode, { duration }); } fadeOut(duration = 1) { fadeOut(this.gainNode, { duration }); } async playOnceReady() { try { await this.play(); } catch (err) { this.once('pause-end', () => this.play()); } } /** * Emit an event indicating a change of property, but only if it changed * @param {String} property - the propertyName * @param {String|Int} newvalue - the new value of the property - ONLY SUPPORTS SCALARS ATM */ notifyUpdated(property, newvalue) { this.$notifyUpdatedPropertyCache = this.$notifyUpdatedPropertyCache || {}; if (this.$notifyUpdatedPropertyCache[property] !== newvalue) { this.fireEvent(property, newvalue); this.$notifyUpdatedPropertyCache[property] = newvalue; } } get adjustedEnd() { return this.adjustedStart + this.offset + this.playDuration; } } export default Controller;