@thelevicole/youtube-to-html5-loader
Version:
A javascript library to load YoutTube videos as HTML5 emebed elements.
510 lines (428 loc) • 15.2 kB
JavaScript
class YouTubeToHtml5 {
static globalHooks = {};
static defaultOptions = {
endpoint: 'https://yt2html5.com/?id=',
selector: 'video[data-yt2html5]',
attribute: 'data-yt2html5',
formats: '*', // Accepts an array of formats e.g. [ '1080p', '720p', '320p' ] or a single format '1080p'. Asterix for all.
autoload: true,
withAudio: false,
withVideo: true
}
class = YouTubeToHtml5;
options = {};
hooks = {};
/**
* @param {{
* endpoint: string,
* selector: string,
* attribute: string,
* formats: string|array,
* autoload: boolean,
* withAudio: boolean,
* withVideo: boolean
* }} options
*/
constructor(options) {
this.options = options;
// Add default load actions.
this.addAction('load.success', this.class._actionLoadSuccess, 0);
this.addAction('load.failed', this.class._actionLoadFailed, 0);
if (this.getOption('autoload')) {
this.load();
}
}
/**
* Get a user or default option.
* @param {string} name
* @param defaultValue
* @returns {*}
*/
getOption(name, defaultValue = null) {
if (!defaultValue && name in this.class.defaultOptions) {
defaultValue = this.class.defaultOptions[name];
}
var value = name in this.options ? this.options[name] : defaultValue;
/**
* Apply value filters to all regardless of option name.
* @example instance.addFilter('option', function(value, name) { return value + 500; });
*/
value = this.applyFilters(`option`, value, name );
/**
* Apply value filters to option named only.
* @example instance.addFilter('setting.delay', function(value) { return value + 500; });
*/
value = this.applyFilters(`option.${name}`, value );
return value;
}
/**
* Get hooks by type and name. Ordered by priority.
* @param {string} type
* @param {string} name
* @returns {array}
*/
getHooks(type, name) {
let hooks = [];
if (type in this.class.globalHooks) {
let globalHooks = this.class.globalHooks[type];
globalHooks = globalHooks.filter(el => el.name === name);
globalHooks = globalHooks.sort((a, b) => a.priority - b.priority);
hooks = hooks.concat(globalHooks);
}
if (type in this.hooks) {
let localHooks = this.hooks[ type ];
localHooks = localHooks.filter(el => el.name === name);
localHooks = localHooks.sort((a, b) => a.priority - b.priority);
hooks = hooks.concat(localHooks);
}
return hooks;
}
/**
* Register a hook.
* @param {string} type
* @param {object} hookMeta
*/
addHook(type, hookMeta) {
// Create new global hook type array.
if (!(type in this.class.globalHooks)) {
this.class.globalHooks[type] = [];
}
// Create new local hook type array.
if (!(type in this.hooks)) {
this.hooks[type] = [];
}
// Add to global.
if ('global' in hookMeta && hookMeta.global) {
this.class.globalHooks[type].push(hookMeta);
}
// Else, add to local.
else {
this.hooks[type].push(hookMeta);
}
}
/**
* Add action callback.
* @param {string} action Name of action to trigger callback on.
* @param {function} callback
* @param {number} priority
* @param {boolean} global True if this action should apply to all instances.
*/
addAction(action, callback, priority = 10, global = false) {
this.addHook('actions', {
name: action,
callback: callback,
priority: priority,
global: global
});
}
/**
* Trigger an action.
* @param {string} name Name of action to run.
* @param {*} args Arguments passed to the callback function.
*/
doAction(name, ...args) {
this.getHooks('actions', name).forEach(hook => {
hook.callback.apply(this, args);
});
}
/**
* Register filter.
* @param {string} filter Name of filter to trigger callback on.
* @param {function} callback
* @param {number} priority
* @param {boolean} global True if this action should apply to all instances.
*/
addFilter(filter, callback, priority = 10, global = false) {
this.addHook('filters', {
name: filter,
callback: callback,
priority: priority,
global: global
});
}
/**
* Apply all named filters to a value.
* @param {string} name Name of action to run.
* @param {*} value The value to be mutated.
* @param {*} args Arguments passed to the callback function.
* @returns {*}
*/
applyFilters(name, value, ...args) {
this.getHooks('filters', name).forEach(hook => {
value = hook.callback.apply(this, [value].concat(args));
});
return value;
}
/**
* Extract the Youtube ID from a URL. Returns full value if no matches.
* @param {string} url
* @returns {string}
*/
urlToId(url) {
const regex = /^(?:http(?:s)?:\/\/)?(?:www\.)?(?:m\.)?(?:youtu\.be\/|(?:(?:youtube-nocookie\.com\/|youtube\.com\/)(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/)))([a-zA-Z0-9\-_]*)/;
const matches = url.match(regex);
return Array.isArray(matches) && matches[1] ? matches[1] : url;
}
/**
* Get list of elements found with the selector.
* @param {NodeList|HTMLCollection|string} selector
* @returns {array}
*/
getElements(selector) {
var elements = null;
if (selector) {
if (NodeList.prototype.isPrototypeOf(selector) || HTMLCollection.prototype.isPrototypeOf(selector)) {
elements = selector;
} else if (typeof selector === 'object' && 'nodeType' in selector && selector.nodeType) {
elements = [selector];
} else {
elements = document.querySelectorAll(this.getOption('selector'));
}
}
elements = Array.from(elements || '');
return this.applyFilters('elements', elements);
}
/**
* Build API url from video id.
* @param {string} videoId
* @returns {string}
*/
requestUrl(videoId) {
const endpoint = this.getOption('endpoint');
const url = endpoint + videoId;
return this.applyFilters('request.url', url, endpoint, videoId);
}
/**
* Sort formats by a list of functions.
*
* @param {object} a
* @param {object} b
* @param {function[]} processors
* @returns {number}
*/
bulkSortBy(a, b, processors) {
let result = 0;
for (let fn of processors) {
const diff = fn(b) - fn(a);
result += diff;
}
return result;
}
/**
* Get stream data from API response.
* @param {object} response
* @returns {array}
*/
getStreamData(response) {
const data = response?.data || {};
let streams = [];
// Build streams array
Array.from(data.formats || '').forEach(stream => {
let thisData = {
_raw: stream,
itag: stream.itag,
url: stream.url,
format: stream.qualityLabel,
type: 'unknown',
mime: 'unknown',
hasAudio: stream.hasAudio,
hasVideo: stream.hasVideo,
browserSupport: 'unknown'
};
if (!thisData.format) {
// Add audio format fallback
if (thisData.hasAudio && !thisData.hasVideo) {
thisData.format = `${stream.audioBitrate}kbps`;
}
}
// Extract stream data from mimetype.
if ('mimeType' in stream) {
const mimeParts = stream.mimeType.match(/^(audio|video)(?:\/([^;]+);)?/i);
// Set media type (video, audo)
if (mimeParts[1]) {
thisData.type = mimeParts[ 1 ];
}
// Set media mime (mp4, ogg...etc)
if (mimeParts[2]) {
thisData.mime = mimeParts[2];
}
// Set browser support rating
thisData.browserSupport = this.canPlayType(`${thisData.type}/${thisData.mime}`);
}
streams.push(thisData);
});
// Sort streams by playability and quality
streams.sort((a, b) => {
return this.bulkSortBy(a, b, [
format => {
return {
'unknown': -1,
'no': -1,
'maybe': 0,
'probably': 1
}[format.browserSupport];
},
format => +!!format._raw.isHLS,
format => +!!format._raw.isDashMPD,
format => +(format._raw.contentLength > 0),
format => +(format.hasVideo && format.hasAudio),
format => +format.hasVideo,
format => parseInt(format.format) || 0,
format => format._raw.bitrate || 0,
format => format._raw.audioBitrate || 0,
format => [
'mp4v',
'avc1',
'Sorenson H.283',
'MPEG-4 Visual',
'VP8',
'VP9',
'H.264',
].findIndex(encoding => format._raw.codecs && format._raw.codecs.includes(encoding)),
format => [
'mp4a',
'mp3',
'vorbis',
'aac',
'opus',
'flac',
].findIndex(encoding => format._raw.codecs && format._raw.codecs.includes(encoding))
]);
});
// Only return streams with audio
if (this.getOption('withAudio')) {
streams = streams.filter(item => item.hasAudio);
}
// Only return streams with video
if (this.getOption('withVideo')) {
streams = streams.filter(item => item.hasVideo);
}
const allowedFormats = this.getOption('formats');
// Filter streams further by allowed formats.
if (allowedFormats !== '*') {
streams = streams.filter(item => Array.from(allowedFormats).includes(item.format));
}
return streams;
}
/**
* Check if a given mime type can be played by the browser.
* @link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canPlayType
* @param {string} type For example "video/mp4"
* @returns {CanPlayTypeResult|string} probably, maybe, no, unkown
*/
canPlayType(type) {
var phantomEl;
if (/^audio/i.test(type)) {
phantomEl = document.createElement('audio');
} else {
phantomEl = document.createElement('video');
}
const value = phantomEl && typeof phantomEl.canPlayType === 'function' ? phantomEl.canPlayType(type) : 'unknown';
return value ? value : 'no';
}
/**
* Run our full process. Loops through each element matching the selector.
*/
load() {
const elements = this.getElements(this.getOption('selector'));
if (elements && elements.length) {
elements.forEach(element => this.loadSingle(element) );
}
}
/**
* Process a single element.
* @param {Element} element
*/
loadSingle(element) {
/**
* Attribute name for grabbing YouTube identifier/url.
*
* @type {string}
*/
const attribute = this.getOption('attribute');
// Check if element has attribute value
if (element.getAttribute(attribute)) {
// Extract video id from attribute value.
const videoId = this.urlToId(element.getAttribute(attribute));
// Build request url.
const requestUrl = this.requestUrl(videoId);
this.doAction('load.before', element);
fetch(requestUrl).then(response => {
response.json().then(json => this.doAction('load.success', element, json));
}).catch(response => {
response.json().then(json => this.doAction('load.failed', element, json));
}).finally(() => {
this.doAction('load.after', element)
});
}
}
/**
* Parse raw YouTube response into usable data.
* @param {YouTubeToHtml5} context
* @param {Element} element
* @param {object} response
*/
static _actionLoadSuccess(context, element, response) {
let streams = context.getStreamData(response);
// Limit to element tag name (video/audio)
streams = streams.filter(item => item.type === element.tagName.toLowerCase());
// Get the top priority stream
const stream = streams.shift();
if (stream) {
element.src = stream.url;
}
}
/**
* Handle failed response.
* @param {YouTubeToHtml5} context
* @param {Element} element
* @param {object} response
*/
static _actionLoadFailed(context, element, response) {
console.warn(`${context.class} was unable to load video.`);
}
}
/**
* Add class to the window's global scope.
*
* @type {YouTubeToHtml5}
*/
window.YouTubeToHtml5 = YouTubeToHtml5;
/**
* Add jQuery plugin if exists.
*/
if (typeof jQuery !== 'undefined') {
(function($) {
/**
*
* @param {{
* endpoint: string,
* formats: string|array,
* autoload: boolean,
* withAudio: boolean,
* withVideo: boolean
* }} options
* @return {YouTubeToHtml5}
*/
$.fn.youtubeToHtml5 = function(options = {}) {
// Cache user default autoload option.
const isAutoload = 'autoload' in options ? options.autoload : YouTubeToHtml5.defaultOptions.autoload;
// For jQuery we will need to make some modifications before we process loading.
options.autoload = false;
// Create new instance.
const controller = new YouTubeToHtml5(options);
// Overide core elements with jQuery selected elements.
controller.addFilter('elements', () => Array.from(this));
// Now we can autoload.
if (isAutoload) {
controller.load();
}
// Return controller instance.
return controller;
}
})(jQuery);
}
/**
* Export module.
*/
export default YouTubeToHtml5;