paella-core
Version:
Multistream HTML video player
499 lines (438 loc) • 16.2 kB
JavaScript
import { Mp4Video } from "./es.upv.paella.mp4VideoFormat";
import VideoPlugin from 'paella-core/js/core/VideoPlugin';
import VideoQualityItem from 'paella-core/js/core/VideoQualityItem';
import AudioTrackData from "paella-core/js/core/AudioTrackData";
import Events, { triggerEvent } from "../core/Events";
import PaellaCoreVideoFormats from "./PaellaCoreVideoFormats";
import Hls from "hls.js";
export const defaultHlsConfig = {
autoStartLoad: true,
startPosition : -1,
capLevelToPlayerSize: true,
debug: false,
defaultAudioCodec: undefined,
initialLiveManifestSize: 1,
maxBufferLength: 6,
maxMaxBufferLength: 6,
maxBufferSize: 600*1000*1000,
maxBufferHole: 0.5,
lowBufferWatchdogPeriod: 0.5,
highBufferWatchdogPeriod: 3,
nudgeOffset: 0.1,
nudgeMaxRetry : 3,
maxFragLookUpTolerance: 0.2,
enableWorker: true,
enableSoftwareAES: true,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 1,
manifestLoadingRetryDelay: 500,
manifestLoadingMaxRetryTimeout : 64000,
startLevel: undefined,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
levelLoadingRetryDelay: 500,
levelLoadingMaxRetryTimeout: 64000,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 500,
fragLoadingMaxRetryTimeout: 64000,
startFragPrefetch: false,
appendErrorMaxRetry: 3,
enableWebVTT: true,
enableCEA708Captions: true,
stretchShortVideoTrack: false,
maxAudioFramesDrift : 1,
forceKeyFrameOnDiscontinuity: true,
abrEwmaFastLive: 5.0,
abrEwmaSlowLive: 9.0,
abrEwmaFastVoD: 4.0,
abrEwmaSlowVoD: 15.0,
abrEwmaDefaultEstimate: 500000,
abrBandWidthFactor: 0.95,
abrBandWidthUpFactor: 0.7,
minAutoBitrate: 0
};
const defaultCorsConfig = {
withCredentials: true,
requestHeaders: {
"Access-Control-Allow-Headers": "Content-Type, Accept, X-Requested-With",
"Access-Control-Allow-Origin": "http://localhost:8000",
"Access-Control-Allow-Credentials": "true"
}
}
export const HlsSupport = {
UNSUPPORTED: 0,
MEDIA_SOURCE_EXTENSIONS: 1,
NATIVE: 2
};
export function getHlsSupport(forceNative = false) {
const video = document.createElement("video");
if (video.canPlayType('application/vnd.apple.mpegurl') && forceNative) {
return HlsSupport.NATIVE;
}
else if (Hls.isSupported()) {
return HlsSupport.MEDIA_SOURCE_EXTENSIONS;
}
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
return HlsSupport.NATIVE;
}
else {
return HlsSupport.UNSUPPORTED;
}
}
const loadHls = (player, streamData, video, config, cors) => {
if (cors.withCredentials) {
config.xhrSetup = function(xhr,url) {
xhr.withCredentials = cors.withCredentials;
for (const header in cors.requestHeaders) {
const value = cors.requestHeaders[header];
xhr.setRequestHeader(header, value);
}
}
}
config.autoStartLoad = true;
const hls = new Hls(config);
const hlsStream = streamData?.sources?.hls?.length>0 &&
streamData.sources.hls[0];
return [hls, new Promise((resolve,reject) => {
let autoQualitySet = false;
hls.on(Hls.Events.LEVEL_SWITCHED, (evt, data) => {
player.log.debug(`HLS: quality level switched to ${data.level}`);
if (!autoQualitySet) {
hls.currentLevel = -1;
autoQualitySet = true;
}
triggerEvent(player, Events.VIDEO_QUALITY_CHANGED, {});
});
hls.on(Hls.Events.ERROR, (event,data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR) {
reject(Error("hlsVideoFormatPlugin: unrecoverable error in HLS player. The video is not available"));
}
else {
player.log.warn("hlsVideoFormatPlugin: Fatal network error. Try to recover");
hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
player.log.warn("hlsVideoFormatPlugin: Fatal media error encountered. Try to recover");
hls.recoverMediaError()
break;
default:
hls.destroy();
reject(Error("hlsVideoFormat: Fatal error. Can not recover"));
}
}
else {
player.log.warn('HLS: error');
player.log.warn(data.details);
}
});
hls.on(Hls.Events.LEVEL_SWITCHING, () => {
player.log.debug("HLS media attached");
});
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
player.log.debug("HLS media attached");
});
hls.on(Hls.Events.MEDIA_DETACHING, () => {
player.log.debug("HLS media detaching");
});
hls.on(Hls.Events.MEDIA_DETACHED, () => {
player.log.debug("HLS media detached");
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
player.log.debug("HLS manifest parsed");
hls.startLoad(-1);
});
const rand = Math.floor(Math.random() * 100000000000);
const url = hlsStream.src + (config.enableCache ?
(/\?/.test(hlsStream.src) ? `&cache=${rand}` : `?cache=${rand}`)
: "");
hls.loadSource(url);
hls.attachMedia(video);
let ready = false;
hls._videoEventListener = () => {
ready = true;
resolve();
};
video.addEventListener("canplay", hls._videoEventListener);
// There are some kind of bug in HLS.js that causes that some
// streams are not loaded until calling video.play()
// This is a workaround for this problem
setTimeout(async () => {
if (!ready) {
await video.play();
await video.pause();
}
}, 1000);
})];
}
export class HlsVideo extends Mp4Video {
constructor(player, parent, config, isMainAudio) {
super(player, parent, isMainAudio, config);
this._config = this._config || {
audioTrackLabel: config.audioTrackLabel || 'name',
enableCache: config.enableCache || false
}
for (const key in defaultHlsConfig) {
this._config[key] = defaultHlsConfig[key];
}
for (const key in config.hlsConfig) {
this._config[key] = config.hlsConfig[key];
}
this._cors = {};
for (const key in defaultCorsConfig) {
this._cors[key] = defaultCorsConfig[key];
}
for (const key in config.corsConfig) {
this._cors[key] = config.corsConfig[key];
}
this._ready = false;
this._autoQuality = true;
this._forceNative = config.forceNative || false;
}
get autoQuality() {
return this._autoQuality;
}
get forceNative() {
return this._forceNative;
}
async loadStreamData(streamData) {
if (getHlsSupport(this.forceNative) === HlsSupport.NATIVE) {
streamData.sources.mp4 = streamData.sources.hls;
const result = await super.loadStreamData(streamData);
const tracks = await this.getAudioTracks();
this._currentAudioTrack = tracks.find(track => track.selected);
this._autoQuality = new VideoQualityItem({
label: "auto",
shortLabel: "auto",
index: -1,
width: 1,
height: 1,
isAuto: true
});
// Initialize current quality
this._currentQuality = this._autoQuality;
this.saveDisabledProperties(this.video);
this._endedCallback = this._endedCallback || (() => {
if (typeof(this._videoEndedCallback) == "function") {
this._videoEndedCallback();
}
});
this.video.addEventListener("ended", this._endedCallback);
return result;
}
else {
this.player.log.debug("Loading HLS stream");
const hlsStream = streamData?.sources?.hls?.length && streamData.sources.hls[0];
this._config.audioTrackLabel = hlsStream?.audioLabel || this._config.audioTrackLabel;
const [hls, promise] = loadHls(this.player, streamData, this.video, this._config, this._cors);
this._hls = hls;
await promise;
this.video.pause();
this._autoQuality = new VideoQualityItem({
label: "auto",
shortLabel: "auto",
index: -1,
width: 1,
height: 1,
isAuto: true
});
// Initialize current quality
this._currentQuality = this._autoQuality;
// Initialize current audio track
const tracks = await this.getAudioTracks();
this._currentAudioTrack = tracks.find(track => track.selected);
this.saveDisabledProperties(this.video);
this._endedCallback = this._endedCallback || (() => {
if (typeof(this._videoEndedCallback) == "function") {
this._videoEndedCallback();
}
});
this.video.addEventListener("ended", this._endedCallback);
}
}
async duration() {
if (this._videoEnabled) {
await this.waitForLoaded();
let duration = this.video.duration;
if (duration === Infinity) {
duration = this._hls?.liveSyncPosition || 0;
}
return duration;
}
else {
return this._disabledProperties.duration;
}
}
async waitForLoaded() {
if (getHlsSupport(this.forceNative) === HlsSupport.NATIVE) {
return super.waitForLoaded();
}
else {
await (new Promise((resolve,reject) => {
const checkReady = () => {
if (this._ready) {
resolve();
}
// Make a special case to allow Firefox to play at readyState 2.
// Browsers like Safari drop from readyState 3 to readyState 2 when the video is
// buffering and cannot be played. Chrome moves quickly between ready state
// 2 to 3 when it is able to play and is not impacted by this issue.
if (/Firefox/.test(navigator.userAgent) && this.video.readyState == 2) {
this._ready = true;
resolve();
}
else if (this.video.readyState > 2) {
this._ready = true;
resolve();
}
else {
setTimeout(() => checkReady(), 200);
}
}
checkReady();
}));
}
}
async getQualities() {
const q = [];
q.push(this._autoQuality);
if (getHlsSupport(this.forceNative) === HlsSupport.MEDIA_SOURCE_EXTENSIONS) {
this._hls.levels.forEach((level, index) => {
q.push(new VideoQualityItem({
index: index, // TODO: should be level.id??
label: `${level.width}x${level.height}`,
shortLabel: `${level.height}p`,
width: level.width,
height: level.height
}));
});
q.sort((a,b) => a.res.h-b.res.h);
}
return q;
}
async setQuality(q) {
if (!this._videoEnabled) {
return;
}
if (!(q instanceof VideoQualityItem)) {
throw Error("Invalid parameter setting video quality. VideoQualityItem object expected.");
}
if (getHlsSupport(this.forceNative) === HlsSupport.MEDIA_SOURCE_EXTENSIONS) {
this._currentQuality = q;
this._hls.currentLevel = q.index;
}
else {
this.player.log.warn("Could not set video quality of HLS stream, because the HLS support of this browser is native.");
}
}
get currentQuality() {
return this._currentQuality;
}
async supportsMultiaudio() {
await this.waitForLoaded();
const hlsSupport = getHlsSupport(this.forceNative);
if (hlsSupport === HlsSupport.MEDIA_SOURCE_EXTENSIONS) {
return this._hls.audioTracks.length > 1;
}
else if (hlsSupport === HlsSupport.NATIVE) {
return this.video.audioTracks?.length > 1;
}
else {
return false;
}
}
async getAudioTracks() {
await this.waitForLoaded();
const audioTrackLabel = this._config.audioTrackLabel || 'name';
const hlsSupport = getHlsSupport(this.forceNative);
if (hlsSupport === HlsSupport.MEDIA_SOURCE_EXTENSIONS) {
const result = this._hls.audioTracks.map(track => {
return new AudioTrackData({
id: track.id,
name: track[audioTrackLabel],
language: track.lang,
selected: this._hls.audioTrack === track.id
});
});
return result;
}
else if (hlsSupport === HlsSupport.NATIVE) {
const result = Array.from(this.video.audioTracks).map(track => {
return new AudioTrackData({
id: track.id,
name: track.label,
language: track.language,
selected: track.enabled
});
});
return result;
}
else {
return null;
}
}
async setCurrentAudioTrack(newTrack) {
await this.waitForLoaded();
const tracks = await this.getAudioTracks();
const selected = tracks.find(track => track.id === newTrack.id);
const hlsSupport = getHlsSupport(this.forceNative);
if (hlsSupport === HlsSupport.MEDIA_SOURCE_EXTENSIONS && selected) {
this._hls.audioTrack = selected.id;
}
else if (hlsSupport === HlsSupport.NATIVE && selected) {
Array.from(this.video.audioTracks).forEach(track => {
if (track.id === selected.id) {
track.enabled = true;
}
else {
track.enabled = false;
}
})
}
this._currentAudioTrack = selected;
return selected;
}
get currentAudioTrack() {
return this._currentAudioTrack;
}
async clearStreamData() {
// See loadHls function
this.video.removeEventListener("canplay", this._hls._videoEventListener);
this.video.src = "";
this._hls.destroy();
this._ready = false;
}
}
export default class HlsVideoPlugin extends VideoPlugin {
getPluginModuleInstance() {
return PaellaCoreVideoFormats.Get();
}
get name() {
return super.name || "es.upv.paella.hlsVideoFormat";
}
get streamType() {
return "hls";
}
isCompatible(streamData) {
const { hls } = streamData.sources;
return hls && getHlsSupport();
}
async getVideoInstance(playerContainer, isMainAudio) {
return new HlsVideo(this.player, playerContainer, this.config, isMainAudio);
}
getCompatibleFileExtensions() {
return ["m3u8"];
}
getManifestData(fileUrls) {
return {
hls: fileUrls.map(url => ({
src: url,
mimetype: 'video/mp4'
}))
};
}
}