import DomSmith from '../../lib/dom/DomSmith.js';
import AsyncTask from '../../lib/util/AsyncTask.js';
import ExtendedMediaError from '../util/ExtendedMediaError.js';
import Looper from '../../lib/util/Looper.js';
import { clone } from '../../lib/util/object.js';
import scriptLoader from '../../lib/util/scriptLoader.js';
/**
* VisionPlayer engine that connects the YouTube IFrame API and the players' media API.
* Emits synthetic media events and mirrors YouTube player state to the VisionPlayer state and controls the YouTube iframe element.
* Supports playback, pause, loop, volume mute, playback rate and seek.
* @exports module:src/providers/YouTube
* @requires module:lib/dom/DomSmith
* @requires module:lib/util/AsyncTask
* @requires module:src/util/ExtendedMediaError
* @requires module:lib/util/Looper
* @requires module:lib/util/object
* @requires module:lib/util/scriptLoader
* @author Frank Kudermann - alphanull
* @version 1.0.0
* @license MIT
*/
export default class YouTube {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {boolean} [lazyLoadLib=true] If `true`, load YouTube Player API on engine activation as opposed to the earlier initialisation stage.
* @property {boolean} [noCookie=true] If `true`, use YouTube's no-cookie domain (youtube-nocookie.com) for the player iframe. Note: The API script itself must still be loaded from youtube.com. This reduces but does not eliminate cookies, and does not provide full GDPR compliance.
*/
#config = {
lazyLoadLib: true,
noCookie: true
};
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Secret key only known to the player instance and initialized components.
* Used to restrict access to API methods in secure mode.
* @type {symbol}
*/
#apiKey;
/**
* Reference to the YouTube player instance (IFrame API).
* @type {Object}
*/
#youtubePlayer;
/**
* Holds metadata information provided by media.load.
* @type {module:src/core/Media~metaData}
*/
#metaData = {};
/**
* Reference to the Async Task instance. Used to handle async tasks, which can be cancelled, resolved or rejected.
* @type {module:lib/util/AsyncTask}
*/
#loadTask;
/**
* Cancelable promise for script loading. Used to prevent callbacks when destroyed.
* @type {Promise|null}
*/
#scriptLoadPromise = null;
/**
* Local state mirror exposed via player state API.
* @type {Object}
*/
#state = YouTube.#defaultState;
/**
* Loop helper for synthetic timeupdate polling.
* @type {module:lib/util/Looper|null}
*/
#timeLoop = null;
/**
* Loop helper for synthetic progress polling.
* @type {module:lib/util/Looper|null}
*/
#progressLoop = null;
/**
* Container element for the YouTube iframe.
* @type {HTMLElement|null}
*/
#container;
/**
* Creates an instance of the YouTube engine.
* @param {module:src/core/Player} player Reference to the player instance.
* @param {Object} parent Reference to the parent component (unused for engines).
* @param {Object} options Additional options.
* @param {symbol} options.apiKey Token for extended access to the player API.
*/
constructor(player, parent, { apiKey }) {
this.#config = player.initConfig('youtube', this.#config);
if (!this.#config) return [false];
this.#player = player;
this.#apiKey = apiKey;
this.#timeLoop = new Looper(() => this.#player.publish('media/timeupdate', this.#apiKey), 150);
this.#progressLoop = new Looper(() => {
this.#updateRanges();
this.#player.publish('media/progress', this.#apiKey);
}, 500);
this.#player.addEngine('youtube', this, {
capabilities: {
play: true,
playbackRate: true,
loop: true,
seek: true,
volume: true,
time: true
}
}, this.#apiKey);
[
['youtube:media.load', this.#load],
['youtube:media.getMetaData', this.#getMetaData],
['youtube:media.canPlay', this.canPlay],
['youtube:media.play', this.#play],
['youtube:media.pause', this.#pause],
['youtube:media.loop', this.#loop],
['youtube:media.playbackRate', this.#playbackRate],
['youtube:media.seek', this.#seek],
['youtube:media.volume', this.#volume],
['youtube:media.mute', this.#mute],
['youtube:media.getElement', this.#getIFrameElement]
].map(([name, handler]) => this.#player.setApi(name, handler, this.#apiKey));
this.#container = new DomSmith({
_ref: 'root',
id: 'vip-engine-youtube',
className: 'vip-engine-wrapper',
_nodes: [{
_ref: 'iframe',
className: 'vip-engine-youtube-iframe'
}]
});
if (!this.#config.lazyLoadLib) this.#scriptLoadPromise = this.#loadLib().catch(() => {});
}
/**
* Checks if this engine can play the given media data, by checking if the source contains a valid YouTube video ID.
* @param {module:src/core/Media~metaData} metaData The data to test.
* @param {string} metaData.src The source URL to test.
* @returns {'probably'|''} Indicates if stream can be played.
*/
canPlay({ src }) { // eslint-disable-line class-methods-use-this
const id = YouTube.#extractVideoId(src);
return id ? 'probably' : '';
}
/**
* Loads YouTube IFrame API via CDN if not present.
* Uses scriptLoader for deduplication, wraps in custom Promise for API-ready callback.
* @returns {Promise<Object>} Cancelable promise that resolves with the YT namespace.
*/
#loadLib() {
// Wrap both script loading AND API ready callback into one cancelable promise
const promise = new Promise((resolve, reject) => {
// If API is already ready, resolve immediately
if (window.YT?.Player) {
resolve(window.YT);
return;
}
// Set up API-ready callback (YouTube-specific mechanism)
const prev = window.onYouTubeIframeAPIReady;
const readyCallback = () => {
prev?.();
if (window.YT?.Player) resolve(window.YT);
else reject(new Error('[VisionPlayer] YouTube IFrame API loaded but YT.Player not available.'));
};
window.onYouTubeIframeAPIReady = readyCallback;
// Use scriptLoader to load the script (handles deduplication)
// Note: The API must always be loaded from youtube.com, not youtube-nocookie.com
const loadPromise = scriptLoader.request('https://www.youtube.com/iframe_api', {
// Note: YT namespace exists but YT.Player only after onYouTubeIframeAPIReady
global: 'YT'
});
// If scriptLoader rejects (error or cancellation), reject this promise too
loadPromise.catch(error => {
// Clean up callback on error
if (window.onYouTubeIframeAPIReady === readyCallback) {
window.onYouTubeIframeAPIReady = prev;
}
if (error.name !== 'AbortError') {
this.#player.dom.getElement(this.#apiKey).classList.add('has-no-media');
this.#player.publish('data/nomedia', this.#apiKey);
this.#player.publish('notification', {
type: 'error',
title: this.#player.locale.t('errors.library.scriptLoaderErrorTitle'),
message: this.#player.locale.t('errors.library.scriptLoaderErrorMessage', { libraryName: 'YouTube' })
}, this.#apiKey);
}
reject(error);
});
});
// Make it cancelable
const cancelablePromise = Object.assign(promise, {
cancel: () => Promise.reject(new Error('AbortError'))
});
// Auto-cleanup when promise settles
cancelablePromise.finally(() => {
if (this.#scriptLoadPromise === cancelablePromise) {
this.#scriptLoadPromise = null;
}
});
return cancelablePromise;
}
/**
* Loads media into the YouTube player.
* @param {module:src/core/Media~metaData} metaData The media data to load.
* @param {Object} [options] Additional options.
* @param {number} [options.seek] Seek position.
* @param {boolean} [options.play] Whether to play after loading.
* @param {boolean} [options.paused=true] When true, keep paused unless `play` overrides.
* @param {number} [options.volume=1] Initial volume between 0 and 1.
* @param {boolean} [options.muted=false] Whether to start muted.
* @param {boolean} [options.ignoreAutoplay] If true, ignore autoplay setting.
* @returns {Promise<module:src/core/Media~metaData>} Resolves with current metadata once API is available.
*/
#load = async(metaData, options = {}) => {
const videoId = YouTube.#extractVideoId(metaData?.src);
if (!videoId) throw new Error('[VisionPlayer] Invalid YouTube video id.');
const { play, paused = true, seek = 0, volume = 1, muted = false } = options,
doPlay = typeof play === 'boolean' ? play : !paused,
prevTask = this.#loadTask;
if (metaData.src && metaData.src === this.#metaData?.src && prevTask?.status === 'pending') return prevTask.promise;
if (prevTask?.status === 'pending') await prevTask.cancel().catch(() => {});
this.#metaData = { ...metaData, videoId };
this.#loadTask = new AsyncTask();
if (this.#youtubePlayer) {
this.#loadVideo(videoId, seek, doPlay, volume, muted);
return this.#loadTask.promise;
}
this.#scriptLoadPromise = this.#loadLib();
let yt;
try {
yt = await this.#scriptLoadPromise;
} catch (error) {
if (error.name === 'AbortError') return; // Cancelled - stop execution
throw error; // Real error - propagate
}
const playerConfig = {
host: this.#config.noCookie ? 'https://www.youtube-nocookie.com' : 'https://www.youtube.com',
playerVars: {
autoplay: play ? 1 : 0,
playsinline: 1,
controls: 0,
disablekb: 1,
fs: 0,
rel: 0,
iv_load_policy: 3, // eslint-disable-line camelcase
origin: typeof window === 'undefined' ? null : window.location.origin,
start: seek > 0 ? seek : 0
},
events: {
onReady: () => { this.#loadVideo(videoId, seek, doPlay, volume, muted); },
onStateChange: this.#onStateChange,
onPlaybackRateChange: () => this.#player.publish('media/ratechange', this.#apiKey),
onError: event => {
const error = new ExtendedMediaError(2, { status: 404, message: `ERROR: ${event?.data || event}` });
this.#player.publish('media/error', { error }, this.#apiKey);
this.#loadTask.reject(error);
}
}
};
this.#youtubePlayer = new yt.Player(this.#container.iframe, playerConfig);
return this.#loadTask.promise;
};
/**
* Cues a video and primes seek/volume/mute state before playback.
* @param {string} videoId Identifier of the YouTube video to load.
* @param {number} seek Start position in seconds for initial cue.
* @param {boolean} play Whether playback should start after cueing.
* @param {number} volume Initial volume fraction (0..1).
* @param {boolean} muted Whether audio starts muted.
*/
#loadVideo = (videoId, seek, play, volume, muted) => {
const args = seek > 0 ? { videoId, startSeconds: seek } : { videoId };
this.#youtubePlayer.cueVideoById(args);
this.#volume(volume);
this.#mute(muted);
this.#progressLoop?.start();
this.#state.savedPlayed = play;
};
/**
* Syncs metadata after the player signals it is cued and emits Media API events.
* @fires module:src/core/Media#media/loadedmetadata
* @fires module:src/core/Media#media/ready
* @fires module:src/core/Media#media/loadeddata
* @fires module:src/core/Media#media/canplay
* @fires module:src/core/Media#media/canplaythrough
* @fires module:src/core/Media#media/progress
* @fires module:src/core/Media#media/timeupdate
*/
#onLoaded = () => {
const data = this.#youtubePlayer?.getVideoData?.();
this.#metaData.videoId = data?.video_id || null;
this.#metaData.title = data?.title || null;
this.#metaData.duration = this.#youtubePlayer.getDuration?.() ?? Infinity;
this.#metaData.isLive = this.#state.liveStream;
this.#metaData.width = this.#state.videoWidth;
this.#metaData.height = this.#state.videoHeight;
this.#updateRanges();
this.#progressLoop?.start();
// Publish (synthetic) media events to match the Media API
this.#player.publish('media/loadedmetadata', this.#apiKey);
this.#player.publish('media/ready', clone(this.#metaData), this.#apiKey);
this.#player.publish('media/loadeddata', this.#apiKey);
this.#player.publish('media/canplay', this.#apiKey);
this.#player.publish('media/canplaythrough', this.#apiKey);
this.#player.publish('media/progress', this.#apiKey);
this.#player.publish('media/timeupdate', this.#apiKey);
if (this.#state.savedPlayed) this.#play();
this.#loadTask.resolve(clone(this.#metaData));
};
/**
* Returns the current metadata.
* @returns {module:src/core/Media~metaData} The current metadata.
*/
#getMetaData = () => this.#metaData;
/**
* Plays the media.
* @fires module:src/core/Media#media/play
*/
#play = () => {
this.#youtubePlayer.playVideo();
this.#timeLoop?.start();
this.#progressLoop?.start();
this.#player.publish('media/play', this.#apiKey);
};
/**
* Pauses the media.
* @fires module:src/core/Media#media/pause
*/
#pause = () => {
this.#youtubePlayer.pauseVideo();
this.#timeLoop?.stop();
this.#player.publish('media/pause', this.#apiKey);
};
/**
* Sets the loop state.
* @param {boolean} loop Whether to loop.
* @fires module:src/core/Media#media/loop
*/
#loop = loop => {
const doLoop = Boolean(loop);
this.#state.doLoop = doLoop;
// YouTube IFrame does not support native loop for single videos; handle on state change.
this.#player.publish('media/loop', this.#apiKey);
};
/**
* Sets the playback rate.
* @param {number} rate The playback rate (0.25 to 2).
* @fires module:src/core/Media#media/ratechange
*/
#playbackRate = rate => {
const parsed = Number(rate);
if (!Number.isFinite(parsed) || parsed <= 0) return;
this.#youtubePlayer.setPlaybackRate(parsed);
this.#player.publish('media/ratechange', this.#apiKey);
};
/**
* Seeks to a specific time.
* @param {number} time The time to seek to (in seconds).
* @fires module:src/core/Media#media/seeked
*/
#seek = time => {
if (this.#youtubePlayer?.getPlayerState?.() === YouTube.#STATE.CUED) {
this.#youtubePlayer.playVideo();
this.#pause();
}
const clamped = Math.max(0, Number(time) || 0);
this.#youtubePlayer.seekTo(clamped, true);
this.#player.publish('media/seeked', this.#apiKey);
};
/**
* Sets the volume.
* @param {number} volume The volume (0 to 1).
* @fires module:src/core/Media#media/volumechange
*/
#volume = volume => {
const vol = Math.min(1, Math.max(0, Number(volume) || 0));
this.#youtubePlayer.setVolume(vol * 100);
this.#player.publish('media/volumechange', this.#apiKey);
};
/**
* Sets the mute state.
* @param {boolean} mute Whether to mute.
* @fires module:src/core/Media#media/volumechange
*/
#mute = mute => {
const doMute = Boolean(mute);
if (doMute) this.#youtubePlayer.mute(); else this.#youtubePlayer.unMute();
this.#state.isMuted = doMute;
this.#player.publish('media/volumechange', this.#apiKey);
};
/**
* Recomputes buffered/played ranges from the current player state.
*/
#updateRanges = () => {
const duration = this.#youtubePlayer?.getDuration?.(),
current = this.#youtubePlayer?.getCurrentTime?.(),
fraction = this.#youtubePlayer?.getVideoLoadedFraction?.();
if (Number.isFinite(duration) && Number.isFinite(current)) {
this.#state.playedRange = YouTube.#toRange(0, Math.min(current, duration));
} else {
this.#state.playedRange = YouTube.#emptyRange;
}
if (Number.isFinite(duration) && Number.isFinite(fraction)) {
this.#state.bufferedRange = YouTube.#toRange(0, Math.min(duration * fraction, duration));
} else {
this.#state.bufferedRange = YouTube.#emptyRange;
}
};
/**
* Maps YouTube player states to Media API events and timers.
* @param {Object} event State change payload from YT IFrame API.
* @fires module:src/core/Media#media/play
* @fires module:src/core/Media#media/playing
* @fires module:src/core/Media#media/pause
* @fires module:src/core/Media#media/ended
* @fires module:src/core/Media#media/waiting
* @fires module:src/core/Media#media/ready
*/
#onStateChange = event => {
const state = event?.data;
switch (state) {
case YouTube.#STATE.PLAYING:
this.#timeLoop?.start();
this.#progressLoop?.start();
this.#state.isPaused = false;
this.#player.publish('media/play', this.#apiKey);
this.#player.publish('media/playing', this.#apiKey);
break;
case YouTube.#STATE.PAUSED:
this.#timeLoop?.stop();
this.#state.isPaused = true;
this.#player.publish('media/pause', this.#apiKey);
break;
case YouTube.#STATE.ENDED:
this.#timeLoop?.stop();
if (this.#state.doLoop) {
this.#youtubePlayer?.seekTo(0, true);
this.#youtubePlayer?.playVideo();
break;
}
this.#state.isPaused = true;
this.#progressLoop?.stop();
this.#player.publish('media/ended', this.#apiKey);
break;
case YouTube.#STATE.BUFFERING:
this.#progressLoop?.start();
this.#player.publish('media/waiting', this.#apiKey);
break;
case YouTube.#STATE.CUED:
this.#onLoaded();
break;
case YouTube.#STATE.UNSTARTED:
break;
default:
break;
}
};
/**
* Returns the YouTube iframe element.
* @returns {HTMLIFrameElement|null} The iframe element or null if not mounted.
*/
#getIFrameElement = () => this.#youtubePlayer?.getIframe?.();
/**
* Enables the YouTube engine.
*/
async enable() {
this.#scriptLoadPromise = this.#loadLib();
try {
await this.#scriptLoadPromise;
} catch (error) {
if (error.name === 'AbortError') return; // Cancelled - stop execution
throw error; // Real error - propagate
}
this.#container.mount({ ele: this.#player.dom.getElement(this.#apiKey) });
this.#state = clone(YouTube.#defaultState);
const stateFuncs = {
src: () => this.#youtubePlayer?.getVideoUrl?.(),
duration: () => this.#youtubePlayer?.getDuration?.() ?? Infinity,
currentTime: () => this.#youtubePlayer?.getCurrentTime?.() ?? 0,
remainingTime: () => {
const dur = this.#youtubePlayer?.getDuration?.(),
cur = this.#youtubePlayer?.getCurrentTime?.();
return Number.isFinite(dur) && Number.isFinite(cur) ? dur - cur : NaN;
},
paused: () => this.#state.isPaused,
ended: () => this.#youtubePlayer?.getPlayerState?.() === YouTube.#STATE.ENDED,
loop: () => Boolean(this.#state.doLoop),
volume: () => Number(this.#youtubePlayer?.getVolume?.()) / 100,
muted: () => this.#state.isMuted, // this.#youtubePlayer?.isMuted?.(),
playbackRate: () => this.#youtubePlayer?.getPlaybackRate?.(),
videoWidth: () => this.#youtubePlayer?.getSize?.().width,
videoHeight: () => this.#youtubePlayer?.getSize?.().height,
buffered: () => this.#state.bufferedRange || YouTube.#emptyRange,
played: () => this.#state.playedRange || YouTube.#emptyRange,
liveStream: () => this.#youtubePlayer?.getVideoData?.()?.isLive || !Number.isFinite(this.#youtubePlayer?.getDuration?.()) || this.#youtubePlayer?.getDuration?.() === 0
};
for (const [key, fn] of Object.entries(stateFuncs)) {
const descriptor = { get: fn, enumerable: true, configurable: true };
Object.defineProperty(this.#state, key, descriptor);
this.#player.setState(`media.${key}`, descriptor, this.#apiKey);
}
}
/**
* Disables the YouTube engine.
*/
disable() {
if (this.#loadTask?.status === 'pending') this.#loadTask.cancel().catch(() => { });
// Cancel script loading if still in progress
if (this.#scriptLoadPromise) {
this.#scriptLoadPromise.cancel().catch(() => {});
this.#scriptLoadPromise = null;
}
Object.keys(this.#state).forEach(key => {
this.#player.removeState(`media.${key}`, this.#apiKey);
delete this.#state[key];
});
this.#youtubePlayer?.destroy?.();
this.#youtubePlayer = null;
this.#container.unmount();
this.#timeLoop?.stop();
this.#progressLoop?.stop();
}
/**
* This method removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
this.disable();
this.#container.destroy();
this.#player.removeEngine('youtube', this.#apiKey);
this.#timeLoop?.destroy();
this.#progressLoop?.destroy();
// eslint-disable-next-line @stylistic/max-len
this.#player.removeApi(['youtube:media.load', 'youtube:media.getMetaData', 'youtube:media.canPlay', 'youtube:media.play', 'youtube:media.pause', 'youtube:media.loop', 'youtube:media.playbackRate', 'youtube:media.seek', 'youtube:media.volume', 'youtube:media.mute', 'youtube:media.getElement'], this.#apiKey);
this.#player = this.#youtubePlayer = this.#metaData = this.#apiKey = this.#timeLoop = this.#progressLoop = null;
}
/**
* Mapping of YouTube IFrame API player states.
* @type {{UNSTARTED:number, ENDED:number, PLAYING:number, PAUSED:number, BUFFERING:number, CUED:number}}
*/
static #STATE = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5
};
/**
* Default state object for the YouTube engine.
* @type {Object}
*/
static #defaultState = {
doLoop: false,
isMuted: false,
isPaused: true,
isEnded: false,
bufferedRange: null,
playedRange: null,
duration: Infinity,
liveStream: false,
currentTime: 0,
remainingTime: NaN,
volume: 1,
playbackRate: 1,
pictureInPicture: false,
videoWidth: NaN,
videoHeight: NaN
};
/**
* Fallback empty TimeRanges-like object.
* @type {{length:number, start:Function, end:Function}}
*/
static #emptyRange = {
length: 0,
start: () => NaN,
end: () => NaN
};
/**
* Creates a minimal TimeRanges-like object for a single range.
* @param {number} start Range start in seconds.
* @param {number} end Range end in seconds.
* @returns {{length:number, start:Function, end:Function}} Range accessor.
*/
static #toRange = (start, end) => {
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return YouTube.#emptyRange;
return {
length: 1,
start: index => (index === 0 ? start : NaN),
end: index => (index === 0 ? end : NaN)
};
};
/**
* Extracts a YouTube video id from a URL.
* @param {string} src Source URL.
* @returns {string|null} Video id if found.
*/
static #extractVideoId = src => {
if (typeof src === 'string' && src.startsWith('youtube:')) {
const directId = src.slice('youtube:'.length).trim();
return /^[A-Za-z0-9_-]{11}$/.test(directId) ? directId : null;
}
let url;
try {
url = new URL(src);
} catch { return null; }
const host = url.hostname.toLowerCase(),
isYouTubeHost = host.includes('youtube.com') || host === 'youtu.be';
if (!isYouTubeHost || url.searchParams.has('list') || url.pathname.startsWith('/playlist')) return null;
const videoIdFromParam = url.searchParams.get('v'),
pathSegments = url.pathname.split('/').filter(Boolean),
videoIdFromPath = host === 'youtu.be' ? pathSegments[0] : pathSegments[1] || pathSegments[0],
videoId = (videoIdFromParam || videoIdFromPath || '').trim();
return /^[A-Za-z0-9_-]{11}$/.test(videoId) ? videoId : null;
};
}