express-bitrate-streamer
Version:
Express-based HLS multi-bitrate streaming backend + tiny frontend player.
124 lines (113 loc) • 4.06 kB
JavaScript
/* global Hls */
export default class BitratePlayer {
/**
* @param {HTMLVideoElement} video
* @param {{log?:(msg:string)=>void}} opts
*/
constructor(video, opts = {}) {
this.video = video;
this.log = opts.log || (() => { });
this.hls = null;
this.source = null;
}
_canPlayNatively(url) {
return this.video.canPlayType('application/vnd.apple.mpegurl') || url.endsWith('.m3u8');
}
async load(url) {
this.destroy();
this.source = url;
if (this._canPlayNatively(url) && !window.Hls) {
this.video.src = url;
await this.video.play().catch(() => { });
this._attachNativeEvents();
this.log('Using native HLS');
} else {
if (!window.Hls) {
throw new Error('hls.js not found. Include it via <script> tag.');
}
if (!window.Hls.isSupported()) {
throw new Error('hls.js is not supported in this browser');
}
this.hls = new window.Hls({
// mild defaults; tweak for latency / stability
maxBufferLength: 30,
liveSyncDurationCount: 3,
enableWorker: true,
});
this.hls.attachMedia(this.video);
this.hls.on(window.Hls.Events.MEDIA_ATTACHED, () => {
this.hls.loadSource(url);
});
this._attachHlsEvents();
await this.video.play().catch(() => { });
this.log('Using hls.js');
}
}
destroy() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.video) {
this.video.src = '';
this.video.removeAttribute('src');
this.video.load();
}
}
getLevels() {
if (this.hls) {
// hls.js provides detailed level info
return (this.hls.levels || []).map(l => ({
bitrate: l.bitrate,
width: l.width,
height: l.height,
name: l.name
}));
}
// native: we can’t read levels; return empty
return [];
}
setLevel(index) {
if (this.hls) {
// -1 => auto
this.hls.nextLevel = index;
} else {
this.log('Native HLS: cannot set fixed level programmatically.');
}
}
_attachHlsEvents() {
const H = window.Hls;
this.hls.on(H.Events.ERROR, (evt, data) => {
this.log(`[hls.js] ERROR: ${data.type} | ${data.details} | ${data.reason || ''}`);
if (data.fatal) {
switch (data.type) {
case H.ErrorTypes.NETWORK_ERROR:
this.hls.startLoad();
break;
case H.ErrorTypes.MEDIA_ERROR:
this.hls.recoverMediaError();
break;
default:
this.hls.destroy();
break;
}
}
});
this.hls.on(H.Events.LEVEL_SWITCHED, (_, data) => {
const lvl = this.hls.levels[data.level];
if (lvl) this.log(`Switched to: ${lvl.height}p (${Math.round(lvl.bitrate / 1000)} kbps)`);
});
this.hls.on(H.Events.MANIFEST_PARSED, () => {
this.log('Manifest parsed');
this.video.play().catch(() => { });
});
}
_attachNativeEvents() {
this.video.addEventListener('error', () => {
const err = this.video.error;
this.log(`[native] video error: ${err && err.message}`);
});
this.video.addEventListener('loadedmetadata', () => this.log('[native] metadata loaded'));
this.video.addEventListener('canplay', () => this.log('[native] canplay'));
}
}