UNPKG

youtube-video-element

Version:

Custom element (web component) for the YouTube player.

536 lines (535 loc) 16.4 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var youtube_video_element_exports = {}; __export(youtube_video_element_exports, { default: () => youtube_video_element_default }); module.exports = __toCommonJS(youtube_video_element_exports); const EMBED_BASE = "https://www.youtube.com/embed"; const EMBED_BASE_NOCOOKIE = "https://www.youtube-nocookie.com/embed"; const API_URL = "https://www.youtube.com/iframe_api"; const API_GLOBAL = "YT"; const API_GLOBAL_READY = "onYouTubeIframeAPIReady"; const VIDEO_MATCH_SRC = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))((\w|-){11})/; const PLAYLIST_MATCH_SRC = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/.*?[?&]list=)([\w_-]+)/; function getTemplateHTML(attrs, props = {}) { const iframeAttrs = { src: serializeIframeUrl(attrs, props), frameborder: 0, width: "100%", height: "100%", allow: "accelerometer; fullscreen; autoplay; encrypted-media; gyroscope; picture-in-picture" }; if (props.config) { iframeAttrs["data-config"] = JSON.stringify(props.config); } return ( /*html*/ ` <style> :host { display: inline-block; line-height: 0; position: relative; min-width: 300px; min-height: 150px; } iframe { position: absolute; top: 0; left: 0; } </style> <iframe${serializeAttributes(iframeAttrs)}></iframe> ` ); } function serializeIframeUrl(attrs, props) { if (!attrs.src) return; const embedBase = attrs.src.includes("-nocookie") ? EMBED_BASE_NOCOOKIE : EMBED_BASE; const params = { // ?controls=true is enabled by default in the iframe controls: attrs.controls === "" ? null : 0, autoplay: attrs.autoplay, loop: attrs.loop, mute: attrs.muted, playsinline: attrs.playsinline, preload: attrs.preload ?? "metadata", // https://developers.google.com/youtube/player_parameters#Parameters // origin: globalThis.location?.origin, enablejsapi: 1, showinfo: 0, rel: 0, iv_load_policy: 3, modestbranding: 1, ...props.config }; if (VIDEO_MATCH_SRC.test(attrs.src)) { const matches2 = attrs.src.match(VIDEO_MATCH_SRC); const srcId = matches2 && matches2[1]; return `${embedBase}/${srcId}?${serialize(params)}`; } const matches = attrs.src.match(PLAYLIST_MATCH_SRC); const playlistId = matches && matches[1]; const extendedParams = { listType: "playlist", list: playlistId, ...params }; return `${embedBase}?${serialize(extendedParams)}`; } class YoutubeVideoElement extends (globalThis.HTMLElement ?? class { }) { static getTemplateHTML = getTemplateHTML; static shadowRootOptions = { mode: "open" }; static observedAttributes = [ "autoplay", "controls", "crossorigin", "loop", "muted", "playsinline", "poster", "preload", "src" ]; loadComplete = new PublicPromise(); #loadRequested; #hasLoaded; #readyState = 0; #seeking = false; #seekComplete; isLoaded = false; #error = null; #config = null; constructor() { super(); this.#upgradeProperty("config"); } get config() { return this.#config; } set config(value) { this.#config = value; } async load() { if (this.#loadRequested) return; if (!this.shadowRoot) { this.attachShadow({ mode: "open" }); } const isFirstLoad = !this.#hasLoaded; if (this.#hasLoaded) { this.loadComplete = new PublicPromise(); this.isLoaded = false; } this.#hasLoaded = true; await (this.#loadRequested = Promise.resolve()); this.#loadRequested = null; this.#readyState = 0; this.dispatchEvent(new Event("emptied")); let oldApi = this.api; this.api = null; if (!this.src) { oldApi == null ? void 0 : oldApi.destroy(); return; } this.dispatchEvent(new Event("loadstart")); let iframe = this.shadowRoot.querySelector("iframe"); let attrs = namedNodeMapToObject(this.attributes); if (isFirstLoad && iframe) { this.#config = JSON.parse(iframe.getAttribute("data-config") || "{}"); } if (!(iframe == null ? void 0 : iframe.src) || iframe.src !== serializeIframeUrl(attrs, this)) { this.shadowRoot.innerHTML = getTemplateHTML(attrs, this); iframe = this.shadowRoot.querySelector("iframe"); } const YT = await loadScript(API_URL, API_GLOBAL, API_GLOBAL_READY); this.api = new YT.Player(iframe, { events: { onReady: () => { this.#readyState = 1; this.dispatchEvent(new Event("loadedmetadata")); this.dispatchEvent(new Event("durationchange")); this.dispatchEvent(new Event("volumechange")); this.dispatchEvent(new Event("loadcomplete")); this.isLoaded = true; this.loadComplete.resolve(); }, onError: (error) => { console.error(error); this.#error = { code: error.data, message: `YouTube iframe player error #${error.data}; visit https://developers.google.com/youtube/iframe_api_reference#onError for the full error message.` }; this.dispatchEvent(new Event("error")); } } }); let playFired = false; this.api.addEventListener("onStateChange", (event) => { var _a; const state = event.data; if (state === YT.PlayerState.PLAYING || state === YT.PlayerState.BUFFERING) { if (!playFired) { playFired = true; this.dispatchEvent(new Event("play")); } } if (state === YT.PlayerState.PLAYING) { if (this.seeking) { this.#seeking = false; (_a = this.#seekComplete) == null ? void 0 : _a.resolve(); this.dispatchEvent(new Event("seeked")); } this.#readyState = 3; this.dispatchEvent(new Event("playing")); } else if (state === YT.PlayerState.PAUSED) { const diff = Math.abs(this.currentTime - lastCurrentTime); if (!this.seeking && diff > 0.1) { this.#seeking = true; this.dispatchEvent(new Event("seeking")); } playFired = false; this.dispatchEvent(new Event("pause")); } if (state === YT.PlayerState.ENDED) { playFired = false; this.dispatchEvent(new Event("pause")); this.dispatchEvent(new Event("ended")); if (this.loop) { this.play(); } } }); this.api.addEventListener("onPlaybackRateChange", () => { this.dispatchEvent(new Event("ratechange")); }); this.api.addEventListener("onVolumeChange", () => { this.dispatchEvent(new Event("volumechange")); }); this.api.addEventListener("onVideoProgress", () => { this.dispatchEvent(new Event("timeupdate")); }); await this.loadComplete; let lastCurrentTime = 0; setInterval(() => { var _a; const diff = Math.abs(this.currentTime - lastCurrentTime); const bufferedEnd = this.buffered.end(this.buffered.length - 1); if (this.seeking && bufferedEnd > 0.1) { this.#seeking = false; (_a = this.#seekComplete) == null ? void 0 : _a.resolve(); this.dispatchEvent(new Event("seeked")); } else if (!this.seeking && diff > 0.1) { this.#seeking = true; this.dispatchEvent(new Event("seeking")); } lastCurrentTime = this.currentTime; }, 50); let lastBufferedEnd; const progressInterval = setInterval(() => { const bufferedEnd = this.buffered.end(this.buffered.length - 1); if (bufferedEnd >= this.duration) { clearInterval(progressInterval); this.#readyState = 4; } if (lastBufferedEnd != bufferedEnd) { lastBufferedEnd = bufferedEnd; this.dispatchEvent(new Event("progress")); } }, 100); } async attributeChangedCallback(attrName, oldValue, newValue) { if (oldValue === newValue) return; switch (attrName) { case "src": case "autoplay": case "controls": case "loop": case "playsinline": { this.load(); } } } async play() { var _a; this.#seekComplete = null; await this.loadComplete; (_a = this.api) == null ? void 0 : _a.playVideo(); return createPlayPromise(this); } async pause() { var _a; await this.loadComplete; return (_a = this.api) == null ? void 0 : _a.pauseVideo(); } get seeking() { return this.#seeking; } get readyState() { return this.#readyState; } get src() { return this.getAttribute("src"); } set src(val) { if (this.src == val) return; this.setAttribute("src", val); } get error() { return this.#error; } /* onStateChange -1 (unstarted) 0 (ended) 1 (playing) 2 (paused) 3 (buffering) 5 (video cued). */ get paused() { var _a, _b; if (!this.isLoaded) return !this.autoplay; return [-1, 0, 2, 5].includes((_b = (_a = this.api) == null ? void 0 : _a.getPlayerState) == null ? void 0 : _b.call(_a)); } get duration() { var _a, _b; return ((_b = (_a = this.api) == null ? void 0 : _a.getDuration) == null ? void 0 : _b.call(_a)) ?? NaN; } get autoplay() { return this.hasAttribute("autoplay"); } set autoplay(val) { if (this.autoplay == val) return; this.toggleAttribute("autoplay", Boolean(val)); } get buffered() { var _a, _b; if (!this.isLoaded) return createTimeRanges(); const progress = ((_a = this.api) == null ? void 0 : _a.getVideoLoadedFraction()) * ((_b = this.api) == null ? void 0 : _b.getDuration()); if (progress > 0) { return createTimeRanges(0, progress); } return createTimeRanges(); } get controls() { return this.hasAttribute("controls"); } set controls(val) { if (this.controls == val) return; this.toggleAttribute("controls", Boolean(val)); } get currentTime() { var _a, _b; return ((_b = (_a = this.api) == null ? void 0 : _a.getCurrentTime) == null ? void 0 : _b.call(_a)) ?? 0; } set currentTime(val) { if (this.currentTime == val) return; this.#seekComplete = new PublicPromise(); this.loadComplete.then(() => { var _a, _b; (_a = this.api) == null ? void 0 : _a.seekTo(val, true); if (this.paused) { (_b = this.#seekComplete) == null ? void 0 : _b.then(() => { var _a2; if (!this.#seekComplete) return; (_a2 = this.api) == null ? void 0 : _a2.pauseVideo(); }); } }); } set defaultMuted(val) { if (this.defaultMuted == val) return; this.toggleAttribute("muted", Boolean(val)); } get defaultMuted() { return this.hasAttribute("muted"); } get loop() { return this.hasAttribute("loop"); } set loop(val) { if (this.loop == val) return; this.toggleAttribute("loop", Boolean(val)); } set muted(val) { if (this.muted == val) return; this.loadComplete.then(() => { var _a, _b; val ? (_a = this.api) == null ? void 0 : _a.mute() : (_b = this.api) == null ? void 0 : _b.unMute(); }); } get muted() { var _a, _b; if (!this.isLoaded) return this.defaultMuted; return (_b = (_a = this.api) == null ? void 0 : _a.isMuted) == null ? void 0 : _b.call(_a); } get playbackRate() { var _a, _b; return ((_b = (_a = this.api) == null ? void 0 : _a.getPlaybackRate) == null ? void 0 : _b.call(_a)) ?? 1; } set playbackRate(val) { if (this.playbackRate == val) return; this.loadComplete.then(() => { var _a; (_a = this.api) == null ? void 0 : _a.setPlaybackRate(val); }); } get playsInline() { return this.hasAttribute("playsinline"); } set playsInline(val) { if (this.playsInline == val) return; this.toggleAttribute("playsinline", Boolean(val)); } get poster() { return this.getAttribute("poster"); } set poster(val) { if (this.poster == val) return; this.setAttribute("poster", `${val}`); } set volume(val) { if (this.volume == val) return; this.loadComplete.then(() => { var _a; (_a = this.api) == null ? void 0 : _a.setVolume(val * 100); }); } get volume() { var _a; if (!this.isLoaded) return 1; return ((_a = this.api) == null ? void 0 : _a.getVolume()) / 100; } // This is a pattern to update property values that are set before // the custom element is upgraded. // https://web.dev/custom-elements-best-practices/#make-properties-lazy #upgradeProperty(prop) { if (Object.prototype.hasOwnProperty.call(this, prop)) { const value = this[prop]; delete this[prop]; this[prop] = value; } } } function serializeAttributes(attrs) { let html = ""; for (const key in attrs) { const value = attrs[key]; if (value === "") html += ` ${escapeHtml(key)}`; else html += ` ${escapeHtml(key)}="${escapeHtml(`${value}`)}"`; } return html; } function escapeHtml(str) { return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;").replace(/`/g, "&#x60;"); } function serialize(props) { return String(new URLSearchParams(boolToBinary(props))); } function boolToBinary(props) { let p = {}; for (let key in props) { let val = props[key]; if (val === true || val === "") p[key] = 1; else if (val === false) p[key] = 0; else if (val != null) p[key] = val; } return p; } function namedNodeMapToObject(namedNodeMap) { let obj = {}; for (let attr of namedNodeMap) { obj[attr.name] = attr.value; } return obj; } const loadScriptCache = {}; async function loadScript(src, globalName, readyFnName) { if (loadScriptCache[src]) return loadScriptCache[src]; if (globalName && self[globalName]) { await delay(0); return self[globalName]; } return loadScriptCache[src] = new Promise(function(resolve, reject) { const script = document.createElement("script"); script.src = src; const ready = () => resolve(self[globalName]); if (readyFnName) self[readyFnName] = ready; script.onload = () => !readyFnName && ready(); script.onerror = reject; document.head.append(script); }); } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function promisify(fn) { return (...args) => new Promise((resolve) => { fn(...args, (...res) => { if (res.length > 1) resolve(res); else resolve(res[0]); }); }); } function createPlayPromise(player) { return promisify((event, cb) => { let fn; player.addEventListener( event, fn = () => { player.removeEventListener(event, fn); cb(); } ); })("playing"); } class PublicPromise extends Promise { constructor(executor = () => { }) { let res, rej; super((resolve, reject) => { executor(resolve, reject); res = resolve; rej = reject; }); this.resolve = res; this.reject = rej; } } function createTimeRanges(start, end) { if (Array.isArray(start)) { return createTimeRangesObj(start); } else if (start == null || end == null || start === 0 && end === 0) { return createTimeRangesObj([[0, 0]]); } return createTimeRangesObj([[start, end]]); } function createTimeRangesObj(ranges) { Object.defineProperties(ranges, { start: { value: (i) => ranges[i][0] }, end: { value: (i) => ranges[i][1] } }); return ranges; } if (globalThis.customElements && !globalThis.customElements.get("youtube-video")) { globalThis.customElements.define("youtube-video", YoutubeVideoElement); } var youtube_video_element_default = YoutubeVideoElement;