@firstcoders/hls-web-audio
Version:
334 lines (274 loc) • 8.31 kB
JavaScript
import Controller from './controller.js';
import Segment from './segment.js';
import Stack from './stack.js';
import parseM3u8 from './lib/parseM3u8.js';
class HLS {
/**
* Internal pointer for optimising scheduling
* @var {Number}
*/
#scheduleNotBefore;
/**
* @param {Object} param - The params
* @param {Object} param.controller - The controller
* @param {Object} param.volume - The initial volume
* @param {Object} param.fetchOptions - Options to use when fetching the hls/m3u8
*/
constructor({
controller,
volume = 1,
fetch = null,
fetchOptions = {},
start = 0,
duration = undefined,
} = {}) {
// optionally set or create controller
this.controller = controller || new Controller();
// register this hls track with the controller
this.controller.observe(this);
// respond to timeupdates
this.eTimeUpdate = this.controller.on('timeupdate', () => this.onTimeUpdate());
// respond to seek
this.eSeek = this.controller.on('seek', () => this.onSeek());
// ensure when the duration changes (e.g. because of offset + play duration), we disconnect any scheduled nodes
// this is because the parameters of those segments may have changed (such as stop time, loop etc)
this.ePlayDuration = this.controller.on('playDuration', () => this.#reset());
this.eOffset = this.controller.on('offset', () => this.#reset());
// create a gainnode for volume
this.gainNode = this.controller.ac.createGain();
// connect this to the destination (normally master gain node)
this.gainNode.connect(this.controller.gainNode);
// initialise the volume
this.volume = volume;
// The stack contains the stack of segments
this.stack = new Stack({ start });
// allows adding to headers for a request
this.fetchOptions = fetchOptions;
// allow injecting fetch
this.fetch = fetch;
// offset the start time
this.start = start;
// duration override
this.duration = duration;
}
set start(start) {
this.stack.start = parseFloat(start);
this.controller?.notify('start', this);
}
get start() {
return this.stack.start;
}
#reset() {
this.stack.disconnectAll();
this.#scheduleNotBefore = undefined;
}
destroy() {
// cancel loading
this.cancel();
// unregister from the controller
this.controller.unobserve(this);
this.controller = null;
// remove event listeners
this.eTimeUpdate.un();
this.eOffset.un();
this.ePlayDuration.un();
this.eSeek.un();
// destroy the stack
this.stack.destroy();
this.stack = null;
}
/**
* Loads the source m3u8 file
*
* @param {String} src
* @returns Object
*/
load(src) {
this.src = src;
const abortController = new AbortController();
const promise = new Promise((resolve, reject) => {
(this.fetch || fetch)(src, {
signal: abortController.signal,
...this.fetchOptions,
headers: {
Accept: 'application/x-mpegURL, application/vnd.apple.mpegurl',
...this.fetchOptions?.headers,
},
})
.then((r) => {
if (!r.ok) {
const error = new Error('HLS Fetch failed');
error.name = 'HLSLoadError';
error.response = r;
throw error;
}
return r;
})
.then((r) => r.text())
.then((r) => parseM3u8(r, src))
.then((r) => {
this.buildSegments(r);
this.controller?.notify('init', this);
resolve(r);
})
.catch((error) => {
// dont consider AbortError an error (todo, reconsider?)
if (error.name === 'AbortError') {
resolve();
}
this.controller?.notify('error', error);
reject(error);
});
});
this.loadHandle = {
promise,
cancel: () => abortController.abort(),
};
return this.loadHandle;
}
/**
* Populates the hls track from a text m3u8 manifest
* @param {String} manifest - The m3u8 manifest
* @param {String} src - The m3u8 location
*/
loadFromM3u8(manifest, src) {
const sources = parseM3u8(manifest, src);
this.buildSegments(sources);
}
/**
* @private
* @param {Array} sources - An array containing the segment data
*/
buildSegments(sources) {
this.stack?.push(
...sources.map((source) => new Segment({ ...source, fetchOptions: this.fetchOptions })),
);
}
set duration(duration) {
this.stack.duration = duration;
this.controller?.notify('duration', this);
}
/**
* Gets the playback duration
*
* @returns Int
*/
get duration() {
return this.stack.duration;
}
/**
* Gets the playback duration
*
* @returns Int
*/
get totalDuration() {
return this.stack.totalDuration;
}
/**
* Gets end time of the sample
*
* @returns Int
*/
get end() {
return this.stack.duration + this.stack.start;
}
/**
* Handles a controller's "tick" event
*
* @private
*/
onTimeUpdate() {
this.runSchedulePass();
}
/**
* Handles a controller's "seek" event
*
* @private
*/
async onSeek() {
// if (this.controller.ac.state === 'running') {
// // eslint-disable-next-line no-console
// console.debug('Disconnecting node when audiocontext is running may cause "ticks"');
// }
// first disconnect everything
this.stack.disconnectAll();
// then run a schedule pass in order to immediately schedule the newly required segments
this.runSchedulePass(true);
}
/**
* Handles a controller's "timeupdate" event
*/
async runSchedulePass(force) {
const timeframe = this.controller.currentTimeframe;
if (force) this.#scheduleNotBefore = undefined;
if (timeframe.currentTime < this.#scheduleNotBefore) {
return;
}
// schedule segments that are needed now
await this.scheduleAt(timeframe);
// schedule segments that may be needed in the next loop
// todo prevent buffering
// await this.scheduleAt(this.controller.calculateFutureTime(5));
}
async scheduleAt(timeframe) {
const { gainNode: destination, controller } = this;
// get the next segment
const segment = this.stack.consume(timeframe);
// if we dont get one, there's nothing to do at this time
if (!segment) return;
try {
// notify to the controller that loading has started
this.controller.notify('loading-start', this);
// load the segment
if (!segment.isLoaded) await segment.load().promise;
const start = timeframe.calculateRealStart(segment);
const offset = timeframe.calculateOffset(segment);
const stop = timeframe.adjustedEnd;
// connect it to the audio
// @todo reverse api to controller.connect(segment) or this.connect(segment)
await segment.connect({ ac: controller.ac, destination, start, offset, stop });
// keep a pointer so we know we dont need to run schedule again prior to a certain currentTime
this.#scheduleNotBefore = segment.end - segment.duration / 2;
this.stack?.recalculateStartTimes();
} catch (err) {
if (err.name !== 'AbortError') {
this.controller?.notify('error', err);
}
} finally {
// release the segment
this.stack?.ack(segment);
// notify to the controller that this segment is ready
this.controller?.notify('loading-end', this);
}
}
get volume() {
return this.gainNode.gain.value;
}
/**
* @param {Int} volume - The volume
*/
set volume(volume) {
this.gainNode.gain.value = volume;
}
/**
* Cancel the loading of the hls playlist
*/
cancel() {
if (this.loadHandle) this.loadHandle.cancel();
}
/**
* Whether the track can play the current semgent based on currentTime
*/
get canPlay() {
const current = this.stack.getAt(this.controller.currentTime);
return current?.isReady;
}
/**
* Whether the track should and can play (depends on whether there is a current segment)
*/
get shouldAndCanPlay() {
const current = this.stack.getAt(this.controller.currentTime);
return !current || current?.isReady;
}
}
export default HLS;